├── 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 | [](https://scrutinizer-ci.com/g/koriym/Koriym.QueryLocator/)
4 | [](https://codecov.io/gh/koriym/Koriym.QueryLocator)
5 | [](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 |
--------------------------------------------------------------------------------