├── tests ├── sql │ └── admin │ │ ├── user.sql │ │ ├── distinct.sql │ │ └── user_count.sql ├── bootstrap.php ├── stubs │ ├── ReturnTypeWillChange.php │ └── apcu.php ├── Fake │ └── RegisterUser.php ├── ApcQueryLocatorTest.php ├── QueryLocatorModuleTest.php └── QueryLocatorTest.php ├── phpstan.neon ├── .github ├── ISSUE_TEMPLATE │ ├── feature.md │ ├── question.md │ └── bug_report.md ├── SECURITY.md └── workflows │ ├── static-analysis.yml │ ├── coding-standards.yml │ ├── continuous-integration.yml │ └── update-copyright-years-in-license-file.yml ├── src ├── Exception │ ├── ExceptionInterface.php │ ├── LogicException.php │ ├── ReadOnlyException.php │ ├── RuntimeException.php │ ├── CountQueryException.php │ └── QueryFileNotFoundException.php ├── QueryProviderInterface.php ├── QueryLocatorInject.php ├── QueryLocatorInterface.php ├── QueryLocatorModule.php ├── ApcQueryLocator.php └── QueryLocator.php ├── .scrutinizer.yml ├── .gitignore ├── codecov.yml ├── composer-require-checker.json ├── phpunit.xml.dist ├── psalm.xml ├── phpcs.xml ├── LICENSE ├── composer.json ├── phpmd.xml └── README.md /tests/sql/admin/user.sql: -------------------------------------------------------------------------------- 1 | SELECT * FROM usr; 2 | -------------------------------------------------------------------------------- /tests/sql/admin/distinct.sql: -------------------------------------------------------------------------------- 1 | SELECT DISTINCT id FROM usr; 2 | -------------------------------------------------------------------------------- /tests/sql/admin/user_count.sql: -------------------------------------------------------------------------------- 1 | SELECT * FROM usr ORDER BY id LIMIT 1 -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | paths: 4 | - src 5 | - tests 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature 3 | about: Suggest a new feature or enhancement 4 | labels: Feature 5 | --- 6 | 7 | 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question regarding software usage 4 | labels: Support 5 | --- 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 10 | 11 | ### How to reproduce 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.github/workflows/update-copyright-years-in-license-file.yml: -------------------------------------------------------------------------------- 1 | name: Update copyright year in license file 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 3 1 1 *" 7 | 8 | jobs: 9 | run: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | - uses: FantasticFiasco/action-update-license-year@v2 16 | with: 17 | token: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | src 6 | 7 | 8 | 9 | 10 | tests 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/ApcQueryLocatorTest.php: -------------------------------------------------------------------------------- 1 | query = new ApcQueryLocator($sqlDir, 'foo-namespace'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/QueryLocatorInject.php: -------------------------------------------------------------------------------- 1 | query = $query; // @codeCoverageIgnore 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/QueryLocatorInterface.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | interface QueryLocatorInterface extends \ArrayAccess 13 | { 14 | /** 15 | * Get SQL 16 | */ 17 | public function get(string $queryName) : string; 18 | 19 | /** 20 | * Get count query SQL 21 | */ 22 | public function getCountQuery(string $queryName) : string; 23 | } 24 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/stubs/apcu.php: -------------------------------------------------------------------------------- 1 | getInstance(QueryLocatorInterface::class); 15 | $this->assertInstanceOf(QueryLocatorInterface::class, $queryLocator); 16 | $sql = $queryLocator->get('admin/user'); 17 | $expected = 'SELECT * FROM usr;'; 18 | $this->assertSame($expected, $sql); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/QueryLocatorModule.php: -------------------------------------------------------------------------------- 1 | sqlDir = $sqlDir; 19 | parent::__construct($module); 20 | } 21 | 22 | /** 23 | * {@inheritdoc} 24 | */ 25 | protected function configure() 26 | { 27 | /** @psalm-suppress InvalidArgument */ 28 | $this->bind()->annotatedWith('sql_dir')->toInstance($this->sqlDir); 29 | /** @psalm-suppress InvalidArgument */ 30 | $this->bind(QueryLocatorInterface::class)->toConstructor(QueryLocator::class, 'sqlDir=sql_dir'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2025 Akihito Koriyama 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "koriym/query-locator", 3 | "description":"Load and manage SQL queries from files", 4 | "keywords":[ 5 | "SQL" 6 | ], 7 | "license": "MIT", 8 | "require": { 9 | "php": ">=7.1.0" 10 | }, 11 | "require-dev": { 12 | "ray/di": "^2.7.2", 13 | "phpunit/phpunit": "^9.5.10", 14 | "vimeo/psalm": "^4.12", 15 | "doctrine/coding-standard": "^9.0", 16 | "phpstan/phpstan": "^1.11", 17 | "phpmd/phpmd": "^2.15" 18 | }, 19 | "autoload":{ 20 | "psr-4":{ 21 | "Koriym\\QueryLocator\\": "src/" 22 | } 23 | }, 24 | "autoload-dev":{ 25 | "psr-4":{ 26 | "Koriym\\QueryLocator\\": ["tests/"], 27 | "MyVendor\\MyPacakge\\": [ "tests/Fake/"] 28 | } 29 | }, 30 | "suggest": { 31 | "ext-apcu": "*" 32 | }, 33 | "scripts" :{ 34 | "test": ["phpunit"], 35 | "tests": ["@cs", "psalm --show-info=false", "@test"], 36 | "coverage": ["php -dzend_extension=xdebug.so -dxdebug.mode=coverage ./vendor/bin/phpunit --coverage-text --coverage-html=build/coverage"], 37 | "cs": ["phpcs --standard=./phpcs.xml src tests"], 38 | "cs-fix": ["phpcbf src tests"], 39 | "sa": ["psalm", "phpstan"] 40 | }, 41 | "config": { 42 | "allow-plugins": { 43 | "dealerdirect/phpcodesniffer-composer-installer": true 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /phpmd.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Koriym.QueryLocator 2 | 3 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/koriym/Koriym.QueryLocator/badges/quality-score.png?b=php8.1-support)](https://scrutinizer-ci.com/g/koriym/Koriym.QueryLocator/) 4 | [![codecov](https://codecov.io/gh/koriym/Koriym.QueryLocator/graph/badge.svg?token=WLZIl7jcaK)](https://codecov.io/gh/koriym/Koriym.QueryLocator) 5 | [![Continuous Integration](https://github.com/koriym/Koriym.QueryLocator/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/koriym/Koriym.QueryLocator/actions/workflows/continuous-integration.yml) 6 | 7 | **Koriym.QueryLocator** is a PHP library that helps you manage SQL queries by locating and loading them from the file system. This approach simplifies query management and enhances code readability. 8 | 9 | ## Installation 10 | 11 | Install the library using Composer: 12 | 13 | ```sh 14 | $ composer require koriym/query-locator 15 | ``` 16 | 17 | ## Usage 18 | 19 | ### Basic Example 20 | 21 | To use the QueryLocator class, instantiate it with the directory where your SQL files are stored. You can then retrieve queries using keys that correspond to the directory structure. 22 | 23 | **SQL Files Directory Structure** 24 | 25 | ```text 26 | └── sql 27 | └── admin 28 | └── user.sql 29 | ``` 30 | **Code Example** 31 | 32 | ```php 33 | use Koriym\QueryLocator\QueryLocator; 34 | 35 | // Define the directory where your SQL files are stored 36 | $sqlDir = 'path/to/sql/files'; 37 | 38 | // Instantiate the QueryLocator 39 | $query = new QueryLocator($sqlDir); 40 | 41 | // Retrieve a query 42 | $sql = $query['admin/user']; // This will load the contents of 'admin/user.sql' 43 | 44 | // Retrieve a count query 45 | $countSql = $query->getCountQuery('admin/user'); // This will generate 'SELECT COUNT(*) FROM user' 46 | ``` 47 | 48 | ## Features 49 | 50 | - **File-Based Query Management**: Store your SQL queries in separate files for better organization. 51 | - **Simple Query Retrieval**: Use directory-based keys to retrieve queries. 52 | - **Count Query Generation**: Automatically generate count queries. 53 | 54 | ## Benefits 55 | 56 | - **Improved Readability**: Keep your PHP code clean and readable by moving SQL queries to dedicated files. 57 | - **Easy Maintenance**: Modify your SQL queries without changing the PHP code, just update the SQL files. 58 | - **Structured Organization**: Organize your queries in a directory structure that makes sense for your application. 59 | -------------------------------------------------------------------------------- /src/ApcQueryLocator.php: -------------------------------------------------------------------------------- 1 | nameSpace = $nameSpace . '-'; 28 | $this->query = new QueryLocator($sqlDir); 29 | } 30 | 31 | /** 32 | * {@inheritdoc} 33 | */ 34 | public function get(string $queryName) : string 35 | { 36 | $sqlId = $this->nameSpace . $queryName; 37 | /** @var ?string $sql */ 38 | $sql = apcu_fetch($sqlId); 39 | if (is_string($sql)) { 40 | return $sql; // @codeCoverageIgnore 41 | } 42 | $sql = $this->query->get($queryName); 43 | apcu_store($sqlId, $sql); 44 | 45 | return $sql; 46 | } 47 | 48 | /** 49 | * {@inheritdoc} 50 | */ 51 | public function getCountQuery(string $queryName) : string 52 | { 53 | $sqlId = $this->nameSpace . $queryName; 54 | $apcuId = __NAMESPACE__ . '-sqlId-' . $sqlId; 55 | /** @var ?string $sql */ 56 | $sql = apcu_fetch($apcuId); 57 | if (is_string($sql)) { 58 | return $sql; // @codeCoverageIgnore 59 | } 60 | $sql = $this->query->getCountQuery($queryName); 61 | apcu_store($apcuId, $sql); 62 | 63 | return $sql; 64 | } 65 | 66 | /** 67 | * {@inheritdoc} 68 | */ 69 | #[ReturnTypeWillChange] 70 | public function offsetExists($offset) 71 | { 72 | return (bool) $this->get($offset); 73 | } 74 | 75 | /** 76 | * {@inheritdoc} 77 | */ 78 | #[ReturnTypeWillChange] 79 | public function offsetGet($offset) 80 | { 81 | return $this->get($offset); 82 | } 83 | 84 | /** 85 | * {@inheritdoc} 86 | */ 87 | #[ReturnTypeWillChange] 88 | public function offsetSet($offset, $value) 89 | { 90 | throw new ReadOnlyException('not supported'); 91 | } 92 | 93 | /** 94 | * {@inheritdoc} 95 | */ 96 | #[ReturnTypeWillChange] 97 | public function offsetUnset($offset) 98 | { 99 | throw new ReadOnlyException('not supported'); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /tests/QueryLocatorTest.php: -------------------------------------------------------------------------------- 1 | query = new QueryLocator($sqlDir); 23 | } 24 | 25 | public function testGetSql(): void 26 | { 27 | $sql = $this->query->get('admin/user'); 28 | $expected = 'SELECT * FROM usr;'; 29 | $this->assertSame($expected, $sql); 30 | } 31 | 32 | public function testGetSqlCached(): void 33 | { 34 | $sql = $this->query->get('admin/user'); 35 | $sql = $this->query->get('admin/user'); 36 | $expected = 'SELECT * FROM usr;'; 37 | $this->assertSame($expected, $sql); 38 | } 39 | 40 | public function testArrayAccess(): void 41 | { 42 | $sql = $this->query['admin/user']; 43 | $expected = 'SELECT * FROM usr;'; 44 | $this->assertSame($expected, $sql); 45 | } 46 | 47 | public function testNotFound(): void 48 | { 49 | $this->expectException(QueryFileNotFoundException::class); 50 | $this->query['user/not_exist_sql']; // @phpstan-ignore-line 51 | } 52 | 53 | public function testGetCountSql(): void 54 | { 55 | $sql = $this->query->getCountQuery('admin/user_count'); 56 | $expected = 'SELECT COUNT(*) FROM usr'; 57 | $this->assertSame($expected, $sql); 58 | } 59 | 60 | public function testGetCountSqlCached(): void 61 | { 62 | $sql = $this->query->getCountQuery('admin/user'); 63 | $expected = 'SELECT COUNT(*) FROM usr;'; 64 | $this->assertSame($expected, $sql); 65 | } 66 | 67 | public function testGetCountSqlFailed(): void 68 | { 69 | $this->expectException(CountQueryException::class); 70 | $this->query->getCountQuery('admin/distinct'); 71 | } 72 | 73 | public function testOffsetExists(): void 74 | { 75 | $isSet = isset($this->query['admin/user']); 76 | $this->assertTrue($isSet); 77 | } 78 | 79 | public function testOffsetUnset(): void 80 | { 81 | $this->expectException(ReadOnlyException::class); 82 | unset($this->query['admin/user']); 83 | } 84 | 85 | public function testSet(): void 86 | { 87 | $this->expectException(ReadOnlyException::class); 88 | $this->query['admin/user'] = 'A SQL'; 89 | } 90 | 91 | public function testNotExists(): void 92 | { 93 | $this->expectException(QueryFileNotFoundException::class); 94 | $this->query['admin/_not_existing_']; // @phpstan-ignore-line 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/QueryLocator.php: -------------------------------------------------------------------------------- 1 | sqlDir = $sqlDir; 24 | } 25 | 26 | /** 27 | * {@inheritdoc} 28 | */ 29 | public function get(string $queryName) : string 30 | { 31 | $sqlFile = sprintf( 32 | '%s/%s.sql', 33 | $this->sqlDir, 34 | $queryName 35 | ); 36 | 37 | return trim($this->getFileContents($sqlFile)); 38 | } 39 | 40 | /** 41 | * {@inheritdoc} 42 | */ 43 | public function getCountQuery(string $queryName) : string 44 | { 45 | return $this->rewriteCountQuery($this->get($queryName)); 46 | } 47 | 48 | /** 49 | * {@inheritdoc} 50 | */ 51 | #[ReturnTypeWillChange] 52 | public function offsetExists($offset) 53 | { 54 | return (bool) $this->get($offset); 55 | } 56 | 57 | /** 58 | * {@inheritdoc} 59 | */ 60 | #[ReturnTypeWillChange] 61 | public function offsetGet($offset) 62 | { 63 | return $this->get($offset); 64 | } 65 | 66 | /** 67 | * {@inheritdoc} 68 | */ 69 | #[ReturnTypeWillChange] 70 | public function offsetSet($offset, $value) 71 | { 72 | throw new ReadOnlyException('not supported'); 73 | } 74 | 75 | /** 76 | * {@inheritdoc} 77 | */ 78 | #[ReturnTypeWillChange] 79 | public function offsetUnset($offset) 80 | { 81 | throw new ReadOnlyException('not supported'); 82 | } 83 | 84 | /** 85 | * Return count query 86 | * 87 | * @see https://github.com/pear/Pager/blob/master/examples/Pager_Wrapper.php 88 | * Taken from pear/pager and modified. 89 | * tested at https://github.com/pear/Pager/blob/80c0e31c8b94f913cfbdeccbe83b63822f42a2f8/tests/pager_wrapper_test.php#L19 90 | * @codeCoverageIgnore 91 | */ 92 | private function rewriteCountQuery(string $sql) : string 93 | { 94 | if (preg_match('/^\s*SELECT\s+\bDISTINCT\b/is', $sql) || preg_match('/\s+GROUP\s+BY\s+/is', $sql)) { 95 | throw new CountQueryException($sql); 96 | } 97 | $openParenthesis = '(?:\()'; 98 | $closeParenthesis = '(?:\))'; 99 | $subQueryInSelect = $openParenthesis . '.*\bFROM\b.*' . $closeParenthesis; 100 | $pattern = '/.*%na' . $subQueryInSelect . 'me.*\bFROM\b\s+/Uims'; 101 | if (preg_match($pattern, $sql)) { 102 | throw new CountQueryException($sql); 103 | } 104 | $subQueryWithLimitOrder = $openParenthesis . '.*\b(LIMIT|ORDER)\b.*' . $closeParenthesis; 105 | $pattern = '/.*\bFROM\b.*.*%na' . $subQueryWithLimitOrder . 'me.*.*/Uims'; 106 | if (preg_match($pattern, $sql)) { 107 | throw new CountQueryException($sql); 108 | } 109 | $queryCount = preg_replace('/.*\bFROM\b\s+/Uims', 'SELECT COUNT(*) FROM ', $sql, 1); 110 | assert(is_string($queryCount)); 111 | [$orderSplit] = preg_split('/\s+ORDER\s+BY\s+/is', $queryCount); // @phpstan-ignore-line 112 | [$limitSplit] = preg_split('/\bLIMIT\b/is', $orderSplit); // @phpstan-ignore-line 113 | 114 | return trim($limitSplit); 115 | } 116 | 117 | private function getFileContents(string $file) : string 118 | { 119 | if (! file_exists($file)) { 120 | throw new QueryFileNotFoundException($file); 121 | } 122 | $contents = (string) file_get_contents($file); 123 | 124 | return $contents; 125 | } 126 | } 127 | --------------------------------------------------------------------------------