├── .gitignore ├── src ├── Exceptions │ ├── DangerousSqlQueryWarning.php │ ├── DuplicateSqlParameter.php │ ├── InvalidSqlTableNameException.php │ ├── InvalidSqlFieldNameException.php │ ├── InvalidSqlParameterException.php │ ├── InvalidSqlAliasNameException.php │ ├── InvalidSqlColumnNameException.php │ ├── UnexpectedSqlFunctionException.php │ └── UnexpectedSqlOperatorException.php ├── Enums │ ├── AggregateFunctions.php │ ├── SqlOperators.php │ └── SqlReservedWords.php ├── Delete.php ├── Insert.php ├── Traits │ └── BindField.php ├── Update.php ├── Query.php ├── SelectJoin.php ├── Where.php └── Select.php ├── psalm.xml ├── tests ├── BindFieldTest.php ├── DeleteTest.php ├── InsertTest.php ├── UpdateTest.php ├── CheckSqlNamesAndParametersTest.php ├── ReadmeTest.php ├── SelectJoinTest.php ├── QueryTest.php ├── SelectTest.php ├── SelectThrowExceptionsTest.php └── WhereClauseTest.php ├── .travis.yml ├── phpunit.xml ├── composer.json ├── LICENSE ├── phpcs.xml ├── README.md └── .php_cs.dist /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .php_cs.cache 3 | .phpunit.result.cache 4 | composer.lock 5 | build 6 | vendor 7 | 8 | temp.php 9 | src/Enums/DBMS.php 10 | src/Enums/JoinClauseTypes.php -------------------------------------------------------------------------------- /src/Exceptions/DangerousSqlQueryWarning.php: -------------------------------------------------------------------------------- 1 | '; 15 | public const EQUAL = '='; 16 | public const LESS_THAN = '<'; 17 | public const LESS_THAN_OR_EQUAL = '<='; 18 | public const MORE_THAN = '>'; 19 | public const MORE_THAN_OR_EQUAL = '>='; 20 | public const IS = 'IS'; 21 | public const IS_NOT = 'IS NOT'; 22 | } 23 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/BindFieldTest.php: -------------------------------------------------------------------------------- 1 | bindField(self::INVALID_NAME, 'field'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Exceptions/DuplicateSqlParameter.php: -------------------------------------------------------------------------------- 1 | getWhereClause(); 19 | if (\is_null($whereClause)) { 20 | throw new DangerousSqlQueryWarning('No WHERE clause in DELETE FROM query'); 21 | } 22 | 23 | $parts = ['DELETE FROM']; 24 | $parts[] = $this->getTableName(); 25 | 26 | $parts[] = 'WHERE'; 27 | $parts[] = \implode(' AND ', $whereClause); 28 | 29 | return \implode(' ', $parts); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidSqlFieldNameException.php: -------------------------------------------------------------------------------- 1 | where('id', SqlOperators::EQUAL, 1, 'id') 21 | ->getQuery() 22 | ; 23 | 24 | static::assertSame('DELETE FROM posts WHERE id = :id', $query); 25 | } 26 | 27 | public function testThrowExceptionOnDangerousDeleteQuery(): void 28 | { 29 | $query = (new Delete('test')); 30 | static::expectException(DangerousSqlQueryWarning::class); 31 | $query->getQuery(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | env: 2 | global: 3 | - CC_TEST_REPORTER_ID=44a54e4dea6cb58bb7af1da3dfd30b384af6218d0717058972ed3fa17393f555 4 | - GIT_COMMITTED_AT=$(if [ "$TRAVIS_PULL_REQUEST" == "false" ]; then git log -1 --pretty=format:%ct; else git log -1 --skip 1 --pretty=format:%ct; fi) 5 | 6 | language: php 7 | 8 | php: 9 | - 7.3 10 | 11 | before_script: 12 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 13 | - chmod +x ./cc-test-reporter 14 | - if [ $(phpenv version-name) = "7.3" ]; then ./cc-test-reporter before-build; fi 15 | 16 | install: 17 | - composer selfupdate 18 | - composer install 19 | 20 | script: 21 | - ./vendor/bin/php-cs-fixer fix . --diff --dry-run 22 | - ./vendor/bin/psalm 23 | - ./vendor/bin/phpunit --coverage-text 24 | 25 | after_script: 26 | - if [ $(phpenv version-name) = "7.3" ] && [ "$TRAVIS_PULL_REQUEST" == "false" ]; then ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT; fi -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | tests 20 | 21 | 22 | 23 | 24 | 25 | src 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "girgias/query-builder", 3 | "description": "A library to build valid SQL queries", 4 | "keywords": ["SQL","query builder", "database"], 5 | "type": "library", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "George Peter Banyard", 10 | "email": "george.banyard@gmail.com", 11 | "role": "Developer" 12 | } 13 | ], 14 | "require": { 15 | "php" : "^7.3", 16 | "girgias/php-enums": "^1.0" 17 | }, 18 | "require-dev": { 19 | "phpunit/phpunit": "^8.0", 20 | "vimeo/psalm": "^3.0", 21 | "phpmd/phpmd": "^2.6", 22 | "squizlabs/php_codesniffer": "^3.3", 23 | "friendsofphp/php-cs-fixer": "^2.14" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "Girgias\\QueryBuilder\\": "src/" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "prs-4": { 32 | "Girgias\\Tests\\": "tests/" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Exceptions/UnexpectedSqlOperatorException.php: -------------------------------------------------------------------------------- 1 | ` (ANSI 'not equal to' operator) ?"; 28 | } 29 | parent::__construct($message, $code, $previous); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Insert.php: -------------------------------------------------------------------------------- 1 | fields)) { 22 | throw new RuntimeException('No fields to update defined'); 23 | } 24 | 25 | $parts = ['INSERT INTO']; 26 | $parts[] = $this->getTableName(); 27 | 28 | $columns = \array_keys($this->fields); 29 | $parts[] = '('.\implode(', ', $columns).')'; 30 | 31 | $parts[] = 'VALUES'; 32 | 33 | $namedParameters = []; 34 | foreach ($this->fields as $namedParameter) { 35 | $namedParameters[] = ':'.$namedParameter; 36 | } 37 | 38 | $parts[] = '('.\implode(', ', $namedParameters).')'; 39 | 40 | return \implode(' ', $parts); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Traits/BindField.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | private $fields; 15 | 16 | /** 17 | * Binds a field to a parameter. 18 | * 19 | * @param string $field 20 | * @param mixed $value 21 | * @param string $namedParameter 22 | * 23 | * @return self 24 | */ 25 | final public function bindField(string $field, $value, ?string $namedParameter = null): self 26 | { 27 | if (!$this->isValidSqlName($field)) { 28 | throw new InvalidSqlFieldNameException($field); 29 | } 30 | 31 | $namedParameter = $this->addStatementParameter($namedParameter, $value); 32 | 33 | $this->fields[$field] = $namedParameter; 34 | 35 | return $this; 36 | } 37 | 38 | abstract protected function addStatementParameter(?string $parameter, $value): string; 39 | 40 | abstract protected function isValidSqlName(string $name): bool; 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 George Peter Banyard 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 | -------------------------------------------------------------------------------- /tests/InsertTest.php: -------------------------------------------------------------------------------- 1 | bindField('username', 'Alice', 'username') 20 | ->getQuery() 21 | ; 22 | 23 | static::assertSame('INSERT INTO posts (username) VALUES (:username)', $query); 24 | } 25 | 26 | public function testInsertQueryWithTwoParameter(): void 27 | { 28 | $query = (new Insert('posts')) 29 | ->bindField('username', 'Alice', 'username') 30 | ->bindField('age', 20, 'age') 31 | ->getQuery() 32 | ; 33 | 34 | static::assertSame('INSERT INTO posts (username, age) VALUES (:username, :age)', $query); 35 | } 36 | 37 | public function testThrowExceptionOnInsertQueryWithoutParameters(): void 38 | { 39 | $query = (new Insert('posts')); 40 | static::expectException(RuntimeException::class); 41 | $query->getQuery(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Update.php: -------------------------------------------------------------------------------- 1 | fields)) { 23 | throw new RuntimeException('No fields to update defined'); 24 | } 25 | $whereClause = $this->getWhereClause(); 26 | if (\is_null($whereClause)) { 27 | throw new DangerousSqlQueryWarning('No WHERE clause in UPDATE query'); 28 | } 29 | 30 | $parts = ['UPDATE']; 31 | $parts[] = $this->getTableName(); 32 | $parts[] = 'SET'; 33 | 34 | $columns = []; 35 | 36 | foreach ($this->fields as $column => $binding) { 37 | $columns[] = $column.' = :'.$binding; 38 | } 39 | $parts[] = \implode(', ', $columns); 40 | 41 | $parts[] = 'WHERE'; 42 | $parts[] = \implode(' AND ', $whereClause); 43 | 44 | return \implode(' ', $parts); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | The coding standard for George Peter "Girgias" Banyard. 4 | vendor/* 5 | tmp/* 6 | */settings/* 7 | */db/* 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/UpdateTest.php: -------------------------------------------------------------------------------- 1 | where('id', SqlOperators::EQUAL, 1, 'id') 22 | ->bindField('username', 'Alice', 'username') 23 | ->getQuery() 24 | ; 25 | 26 | static::assertSame('UPDATE posts SET username = :username WHERE id = :id', $query); 27 | } 28 | 29 | public function testUpdateQueryWithTwoParameter(): void 30 | { 31 | $query = (new Update('posts')) 32 | ->where('id', SqlOperators::EQUAL, 1, 'id') 33 | ->bindField('username', 'Alice', 'username') 34 | ->bindField('age', 20, 'age') 35 | ->getQuery() 36 | ; 37 | 38 | static::assertSame('UPDATE posts SET username = :username, age = :age WHERE id = :id', $query); 39 | } 40 | 41 | public function testThrowExceptionOnUpdateQueryWithoutParameters(): void 42 | { 43 | $query = (new Update('posts')) 44 | ->where('id', SqlOperators::EQUAL, 1, 'id') 45 | ; 46 | static::expectException(RuntimeException::class); 47 | $query->getQuery(); 48 | } 49 | 50 | public function testThrowExceptionOnDangerousUpdateQuery(): void 51 | { 52 | $query = (new Update('test')); 53 | $query->bindField('field1', 'field1'); 54 | static::expectException(DangerousSqlQueryWarning::class); 55 | $query->getQuery(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/CheckSqlNamesAndParametersTest.php: -------------------------------------------------------------------------------- 1 | setAccessible(true); 24 | 25 | static::assertTrue($method->invoke($query, 'p')); 26 | static::assertTrue($method->invoke($query, 'p.field')); 27 | static::assertTrue($method->invoke($query, 'co.user')); 28 | static::assertTrue($method->invoke($query, 'p.table.col')); 29 | static::assertTrue($method->invoke($query, 'semi_long_table_name')); 30 | static::assertTrue($method->invoke($query, 'semi_lo43g_table_n22e')); 31 | 32 | static::assertFalse($method->invoke($query, '58semi_lo43g_table_n22e')); 33 | static::assertFalse($method->invoke($query, '2col')); 34 | static::assertFalse($method->invoke($query, 'p.')); 35 | static::assertFalse($method->invoke($query, 'p.table.')); 36 | } 37 | 38 | /** 39 | * @covers \Girgias\QueryBuilder\Query::isValidSqlParameter 40 | */ 41 | public function testIsValidSqlParameter(): void 42 | { 43 | $query = static::getMockForAbstractClass(Query::class, [], '', false); 44 | $method = new ReflectionMethod($query, 'isValidSqlParameter'); 45 | $method->setAccessible(true); 46 | 47 | static::assertTrue($method->invoke($query, 'p')); 48 | static::assertTrue($method->invoke($query, 'ID')); 49 | static::assertTrue($method->invoke($query, 'namedParameter')); 50 | static::assertTrue($method->invoke($query, 'Name')); 51 | static::assertTrue($method->invoke($query, 'category')); 52 | 53 | static::assertFalse($method->invoke($query, 'p.field')); 54 | static::assertFalse($method->invoke($query, 'co.user')); 55 | static::assertFalse($method->invoke($query, 'p.table.col')); 56 | static::assertFalse($method->invoke($query, 'semi_long_table_name')); 57 | static::assertFalse($method->invoke($query, 'semi_lo43g_table_n22e')); 58 | static::assertFalse($method->invoke($query, '58semi_lo43g_table_n22e')); 59 | static::assertFalse($method->invoke($query, '2col')); 60 | static::assertFalse($method->invoke($query, 'p.')); 61 | static::assertFalse($method->invoke($query, 'p.table.')); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/ReadmeTest.php: -------------------------------------------------------------------------------- 1 | limit(10, 20) 18 | ->order('published_date') 19 | ->getQuery() 20 | ; 21 | 22 | static::assertSame( 23 | 'SELECT * FROM demo ORDER BY published_date ASC LIMIT 10 OFFSET 20', 24 | $query 25 | ); 26 | } 27 | 28 | public function testReadmeExample2(): void 29 | { 30 | $start = new \DateTime('01/01/2016'); 31 | $end = new \DateTime('01/01/2017'); 32 | $query = (new \Girgias\QueryBuilder\Select('demo')) 33 | ->select('title', 'slug') 34 | ->selectAs('name_author_post', 'author') 35 | ->whereBetween('date_published', $start, $end) 36 | ->order('date_published', 'DESC') 37 | ->limit(25) 38 | ->getQuery() 39 | ; 40 | 41 | static::assertSame( 42 | 'SELECT title, slug, name_author_post AS author FROM demo WHERE date_published '. 43 | "BETWEEN '2016-01-01 00:00:00' AND '2017-01-01 00:00:00' ORDER BY date_published DESC LIMIT 25", 44 | $query 45 | ); 46 | } 47 | 48 | public function testReadmeExample3(): void 49 | { 50 | $query = (new \Girgias\QueryBuilder\Select('demo')) 51 | ->where('author', '=', 'Alice', 'author') 52 | ->whereOr('editor', '=', 'Alice', 'editor') 53 | ->getQuery() 54 | ; 55 | 56 | static::assertSame( 57 | 'SELECT * FROM demo WHERE (author = :author OR editor = :editor)', 58 | $query 59 | ); 60 | } 61 | 62 | public function testReadmeExample4(): void 63 | { 64 | $query = (new \Girgias\QueryBuilder\Update('posts')) 65 | ->where('id', '=', 1, 'id') 66 | ->bindField('title', 'This is a title', 'title') 67 | ->bindField('content', 'Hello World', 'content') 68 | ->bindField('date_last_edited', (new \DateTimeImmutable()), 'nowDate') 69 | ->getQuery() 70 | ; 71 | 72 | static::assertSame( 73 | 'UPDATE posts SET title = :title, content = :content, date_last_edited = :nowDate WHERE id = :id', 74 | $query 75 | ); 76 | } 77 | 78 | public function testReadmeExample5(): void 79 | { 80 | $query = (new \Girgias\QueryBuilder\SelectJoin('comments', 'posts')) 81 | ->tableAlias('co') 82 | ->select('co.user', 'co.content', 'p.title') 83 | ->joinTableAlias('p') 84 | ->innerJoin('post_id', 'id') 85 | ->getQuery() 86 | ; 87 | 88 | static::assertSame( 89 | 'SELECT co.user, co.content, p.title FROM comments AS co INNER JOIN posts AS p ON comments.post_id = posts.id', 90 | $query 91 | ); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Query.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | private $parameters = []; 31 | 32 | /** 33 | * Query constructor. 34 | * 35 | * @param string $table 36 | */ 37 | public function __construct(string $table) 38 | { 39 | if (!$this->isValidSqlName($table)) { 40 | throw new InvalidSqlTableNameException($table); 41 | } 42 | 43 | $this->table = $table; 44 | } 45 | 46 | /** 47 | * Return built Query. 48 | * 49 | * @return string 50 | */ 51 | abstract public function getQuery(): string; 52 | 53 | final public function getParameters(): array 54 | { 55 | return $this->parameters; 56 | } 57 | 58 | final protected function getTableName(): string 59 | { 60 | return $this->table; 61 | } 62 | 63 | /** 64 | * @param null|string $parameter 65 | * @param mixed $value 66 | * 67 | * @return string named parameter 68 | */ 69 | final protected function addStatementParameter(?string $parameter, $value): string 70 | { 71 | if ($value instanceof DateTimeInterface) { 72 | $value = $value->format(self::SQL_DATE_FORMAT); 73 | } 74 | 75 | if (!\is_scalar($value)) { 76 | throw new \InvalidArgumentException('Statement parameter value must be a scalar.'); 77 | } 78 | 79 | if (\is_null($parameter)) { 80 | $parameter = $this->generateSqlParameter(); 81 | if (\array_key_exists($parameter, $this->parameters)) { 82 | return $this->addStatementParameter(null, $value); 83 | } 84 | } 85 | 86 | if (!$this->isValidSqlParameter($parameter)) { 87 | throw new InvalidSqlParameterException($parameter); 88 | } 89 | 90 | if (\array_key_exists($parameter, $this->parameters)) { 91 | throw new DuplicateSqlParameter($parameter); 92 | } 93 | 94 | $this->parameters[$parameter] = $value; 95 | 96 | return $parameter; 97 | } 98 | 99 | /** 100 | * Checks if argument is a valid SQL name. 101 | * 102 | * @param string $name 103 | * 104 | * @return bool 105 | */ 106 | final protected function isValidSqlName(string $name): bool 107 | { 108 | if (1 === \preg_match(self::SQL_NAME_PATTERN, $name) && 109 | !\in_array(\strtoupper($name), SqlReservedWords::RESERVED_WORDS, true) 110 | ) { 111 | return true; 112 | } 113 | 114 | return false; 115 | } 116 | 117 | final private function generateSqlParameter(): string 118 | { 119 | $string = ''; 120 | $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; 121 | for ($i = 0; $i < 10; ++$i) { 122 | $string .= \substr($chars, \mt_rand(0, \strlen($chars)), 1); 123 | } 124 | 125 | return $string; 126 | } 127 | 128 | final private function isValidSqlParameter(string $parameter): bool 129 | { 130 | if (1 === \preg_match(self::SQL_PARAMETER_PATTERN, $parameter)) { 131 | return true; 132 | } 133 | 134 | return false; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /tests/SelectJoinTest.php: -------------------------------------------------------------------------------- 1 | joinTableAlias('t') 30 | ->naturalJoin() 31 | ->getQuery() 32 | ; 33 | 34 | static::assertSame('SELECT * FROM demo NATURAL JOIN test AS t', $query); 35 | } 36 | 37 | public function testThrowExceptionOnInvalidTableAlias(): void 38 | { 39 | $query = (new SelectJoin('demo', 'test')); 40 | static::expectException(InvalidSqlAliasNameException::class); 41 | $query->joinTableAlias(self::INVALID_NAME); 42 | } 43 | 44 | public function testThrowExceptionWithoutJoinType(): void 45 | { 46 | $query = (new SelectJoin('demo', 'test')); 47 | static::expectException(\DomainException::class); 48 | $query->getQuery(); 49 | } 50 | 51 | public function testCrossJoin(): void 52 | { 53 | $query = (new SelectJoin('demo', 'test')) 54 | ->crossJoin() 55 | ->getQuery() 56 | ; 57 | 58 | static::assertSame('SELECT * FROM demo CROSS JOIN test', $query); 59 | } 60 | 61 | public function testNaturalJoin(): void 62 | { 63 | $query = (new SelectJoin('demo', 'test')) 64 | ->naturalJoin() 65 | ->getQuery() 66 | ; 67 | 68 | static::assertSame('SELECT * FROM demo NATURAL JOIN test', $query); 69 | } 70 | 71 | public function testFullJoin(): void 72 | { 73 | $query = (new SelectJoin('demo', 'test')) 74 | ->fullJoin('test_id', 'id') 75 | ->getQuery() 76 | ; 77 | 78 | static::assertSame('SELECT * FROM demo FULL JOIN test ON demo.test_id = test.id', $query); 79 | } 80 | 81 | public function testInnerJoin(): void 82 | { 83 | $query = (new SelectJoin('demo', 'test')) 84 | ->innerJoin('test_id', 'id') 85 | ->getQuery() 86 | ; 87 | 88 | static::assertSame('SELECT * FROM demo INNER JOIN test ON demo.test_id = test.id', $query); 89 | } 90 | 91 | public function testLeftJoin(): void 92 | { 93 | $query = (new SelectJoin('demo', 'test')) 94 | ->leftJoin('test_id', 'id') 95 | ->getQuery() 96 | ; 97 | 98 | static::assertSame('SELECT * FROM demo LEFT JOIN test ON demo.test_id = test.id', $query); 99 | } 100 | 101 | public function testRightJoin(): void 102 | { 103 | $query = (new SelectJoin('demo', 'test')) 104 | ->rightJoin('test_id', 'id') 105 | ->getQuery() 106 | ; 107 | 108 | static::assertSame('SELECT * FROM demo RIGHT JOIN test ON demo.test_id = test.id', $query); 109 | } 110 | 111 | public function testThrowExceptionOnInvalidJoinBaseColumn(): void 112 | { 113 | $query = (new SelectJoin('demo', 'test')); 114 | static::expectException(InvalidSqlColumnNameException::class); 115 | $query->fullJoin(self::INVALID_NAME, 'id'); 116 | } 117 | 118 | public function testThrowExceptionOnInvalidSelectColumn(): void 119 | { 120 | $query = (new SelectJoin('demo', 'test')); 121 | static::expectException(InvalidSqlColumnNameException::class); 122 | $query->fullJoin('test_id', self::INVALID_NAME); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/SelectJoin.php: -------------------------------------------------------------------------------- 1 | 30 | */ 31 | private $joinOn; 32 | 33 | public function __construct(string $table, string $joinTable) 34 | { 35 | parent::__construct($table); 36 | 37 | if (!$this->isValidSqlName($joinTable)) { 38 | throw new InvalidSqlTableNameException($joinTable); 39 | } 40 | 41 | $this->joinTable = $joinTable; 42 | } 43 | 44 | final public function joinTableAlias(string $alias): self 45 | { 46 | if (!$this->isValidSqlName($alias)) { 47 | throw new InvalidSqlAliasNameException('JOIN TABLE ALIAS', $alias); 48 | } 49 | $this->joinTableAlias = $alias; 50 | 51 | return $this; 52 | } 53 | 54 | final public function crossJoin(): self 55 | { 56 | $this->joinType = 'CROSS'; 57 | 58 | return $this; 59 | } 60 | 61 | final public function fullJoin(string $column, string $fkColumn): self 62 | { 63 | $this->joinOn($column, $fkColumn, 'FULL JOIN'); 64 | 65 | $this->joinType = 'FULL'; 66 | 67 | return $this; 68 | } 69 | 70 | final public function innerJoin(string $column, string $fkColumn): self 71 | { 72 | $this->joinType = 'INNER'; 73 | 74 | $this->joinOn($column, $fkColumn, 'INNER JOIN'); 75 | 76 | return $this; 77 | } 78 | 79 | final public function leftJoin(string $column, string $fkColumn): self 80 | { 81 | $this->joinType = 'LEFT'; 82 | 83 | $this->joinOn($column, $fkColumn, 'LEFT JOIN'); 84 | 85 | return $this; 86 | } 87 | 88 | final public function naturalJoin(): self 89 | { 90 | $this->joinType = 'NATURAL'; 91 | 92 | return $this; 93 | } 94 | 95 | final public function rightJoin(string $column, string $fkColumn): self 96 | { 97 | $this->joinType = 'RIGHT'; 98 | 99 | $this->joinOn($column, $fkColumn, 'RIGHT JOIN'); 100 | 101 | return $this; 102 | } 103 | 104 | /** 105 | * Build SELECT query from parameters. 106 | * 107 | * @return string 108 | */ 109 | final public function getQuery(): string 110 | { 111 | $parts = $this->buildBeginningSelectQuery(); 112 | 113 | \array_push($parts, ...$this->buildJoinClause()); 114 | 115 | \array_push($parts, ...$this->buildSqlClauses()); 116 | 117 | return \implode(' ', $parts); 118 | } 119 | 120 | final private function joinOn(string $column, string $fkColumn, string $joinType): void 121 | { 122 | if (!$this->isValidSqlName($column)) { 123 | throw new InvalidSqlColumnNameException($joinType, $column); 124 | } 125 | if (!$this->isValidSqlName($fkColumn)) { 126 | throw new InvalidSqlColumnNameException($joinType, $fkColumn); 127 | } 128 | 129 | $this->joinOn = [ 130 | 'ON', 131 | $this->getTableName().'.'.$column, 132 | '=', 133 | $this->joinTable.'.'.$fkColumn, 134 | ]; 135 | } 136 | 137 | final private function buildJoinClause(): array 138 | { 139 | if (\is_null($this->joinType)) { 140 | throw new \DomainException('Cannot build Join Clause without a selected join type'); 141 | } 142 | 143 | $joinClause = [ 144 | $this->joinType, 145 | 'JOIN', 146 | $this->joinTable, 147 | ]; 148 | 149 | if (!\is_null($this->joinTableAlias)) { 150 | $joinClause[] = 'AS'; 151 | $joinClause[] = $this->joinTableAlias; 152 | } 153 | 154 | if ('NATURAL' !== $this->joinType && 'CROSS' !== $this->joinType) { 155 | \array_push($joinClause, ...$this->joinOn); 156 | } 157 | 158 | return $joinClause; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /tests/QueryTest.php: -------------------------------------------------------------------------------- 1 | setAccessible(true); 34 | $randomString = $method->invoke($stub); 35 | static::assertSame(1, \preg_match('/^[a-zA-Z]{10}$/', $randomString)); 36 | } 37 | 38 | /** 39 | * @covers \Girgias\QueryBuilder\Query::addStatementParameter 40 | */ 41 | public function testAddParameters(): void 42 | { 43 | $stub = static::getMockForAbstractClass(Query::class, [], '', false); 44 | 45 | $method = new ReflectionMethod($stub, 'addStatementParameter'); 46 | $method->setAccessible(true); 47 | $method->invoke($stub, 'named', 'test'); 48 | 49 | $property = $method->getDeclaringClass()->getProperty('parameters'); 50 | $property->setAccessible(true); 51 | 52 | static::assertSame(['named' => 'test'], $property->getValue($stub)); 53 | } 54 | 55 | public function testAddParameterWithNullName(): void 56 | { 57 | $stub = static::getMockForAbstractClass(Query::class, [], '', false); 58 | 59 | $method = new ReflectionMethod($stub, 'addStatementParameter'); 60 | $method->setAccessible(true); 61 | $method->invoke($stub, null, 'test'); 62 | 63 | $property = $method->getDeclaringClass()->getProperty('parameters'); 64 | $property->setAccessible(true); 65 | 66 | static::assertCount(1, $property->getValue($stub)); 67 | } 68 | 69 | public function testAddParameterWithNullNamesRecursive(): void 70 | { 71 | $seed = 45632; 72 | \mt_srand($seed); 73 | 74 | $stub = static::getMockForAbstractClass(Query::class, [], '', false); 75 | 76 | $method = new ReflectionMethod($stub, 'addStatementParameter'); 77 | $method->setAccessible(true); 78 | $method->invoke($stub, null, 'test'); 79 | 80 | \mt_srand($seed); 81 | 82 | $method->invoke($stub, null, 'recursion'); 83 | 84 | $property = $method->getDeclaringClass()->getProperty('parameters'); 85 | $property->setAccessible(true); 86 | 87 | static::assertCount(2, $property->getValue($stub)); 88 | } 89 | 90 | public function testAddParametersWithDateTimeInterfaceValue(): void 91 | { 92 | $stub = static::getMockForAbstractClass(Query::class, [], '', false); 93 | 94 | $method = new ReflectionMethod($stub, 'addStatementParameter'); 95 | $method->setAccessible(true); 96 | $method->invoke($stub, 'named', (new \DateTimeImmutable('2019-02-10'))); 97 | 98 | $property = $method->getDeclaringClass()->getProperty('parameters'); 99 | $property->setAccessible(true); 100 | 101 | static::assertSame(['named' => '2019-02-10 00:00:00'], $property->getValue($stub)); 102 | } 103 | 104 | public function testAddParameterExceptionOnInvalidValueType(): void 105 | { 106 | $stub = static::getMockForAbstractClass(Query::class, [], '', false); 107 | 108 | $method = new ReflectionMethod($stub, 'addStatementParameter'); 109 | $method->setAccessible(true); 110 | 111 | static::expectException(\InvalidArgumentException::class); 112 | $method->invoke($stub, 'param', []); 113 | } 114 | 115 | public function testAddParameterExceptionOnInvalidParameterName(): void 116 | { 117 | $stub = static::getMockForAbstractClass(Query::class, [], '', false); 118 | 119 | $method = new ReflectionMethod($stub, 'addStatementParameter'); 120 | $method->setAccessible(true); 121 | 122 | static::expectException(InvalidSqlParameterException::class); 123 | $method->invoke($stub, '25invalidName', 'test'); 124 | } 125 | 126 | public function testAddParameterExceptionOnDuplicateParameterName(): void 127 | { 128 | $stub = static::getMockForAbstractClass(Query::class, [], '', false); 129 | 130 | $method = new ReflectionMethod($stub, 'addStatementParameter'); 131 | $method->setAccessible(true); 132 | $method->invoke($stub, 'duplicate', 'test'); 133 | 134 | static::expectException(DuplicateSqlParameter::class); 135 | $method->invoke($stub, 'duplicate', 'demo'); 136 | } 137 | 138 | public function testGetParameters(): void 139 | { 140 | $stub = static::getMockForAbstractClass(Query::class, [], '', false); 141 | 142 | static::assertSame([], $stub->getParameters()); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SQL Query Builder 2 | [![Build Status](https://travis-ci.org/Girgias/query-builder.svg?branch=master)](https://travis-ci.org/Girgias/query-builder) 3 | [![Maintainability](https://api.codeclimate.com/v1/badges/e804486b68df4080cead/maintainability)](https://codeclimate.com/github/Girgias/query-builder/maintainability) 4 | [![Test Coverage](https://api.codeclimate.com/v1/badges/e804486b68df4080cead/test_coverage)](https://codeclimate.com/github/Girgias/query-builder/test_coverage) 5 | 6 | A fluent SQL Query Builder which **ONLY** builds a valid SQL query with the SQL 7 | clauses it has been asked to provide. 8 | 9 | ## Installing 10 | 11 | ```shell 12 | composer require girgias/query-builder 13 | ``` 14 | 15 | ## Features 16 | 17 | This Query Builder can build a variety of SQL queries which are database agnostic 18 | as it uses the ANSI standardized syntax. 19 | 20 | Every sort of query has its own class which extends from the base ``Query`` class, 21 | They all have the same constructor signature which requires the table name 22 | on which to execute the query. 23 | 24 | To build a SELECT query with table join use the ``SelectJoin`` class. 25 | An example can be seen below on how to use this class. 26 | 27 | It is possible to directly provide scalar values to WHERE clauses and while binding a field. 28 | It is also possible to specify the named parameter against which the value will be bounded. 29 | In case no named parameter has been provided a random one will be generated. 30 | 31 | To retrieve the named parameters with their associated values use the ``getParameters`` 32 | method which will return an associative array ``parameter => value``. 33 | 34 | ### Examples 35 | A basic ``SELECT`` query: 36 | ```php 37 | $query = (new \Girgias\QueryBuilder\Select('demo')) 38 | ->limit(10, 20) 39 | ->order('published_date') 40 | ->getQuery(); 41 | ``` 42 | Will output: 43 | ```sql 44 | SELECT * FROM demo ORDER BY published_date ASC LIMIT 10 OFFSET 20 45 | ``` 46 | 47 | A more complex ``SELECT`` query: 48 | ```php 49 | $start = new \DateTime('01/01/2016'); 50 | $end = new \DateTime('01/01/2017'); 51 | $query = (new \Girgias\QueryBuilder\Select('demo')) 52 | ->select('title', 'slug') 53 | ->selectAs('name_author_post', 'author') 54 | ->whereBetween('date_published', $start, $end) 55 | ->order('date_published', 'DESC') 56 | ->limit(25) 57 | ->getQuery(); 58 | ``` 59 | 60 | Will output: 61 | ```sql 62 | SELECT title, slug, name_author_post AS author FROM demo WHERE date_published BETWEEN '2016-01-01 00:00:00' AND '2017-01-01 00:00:00' ORDER BY date_published DESC LIMIT 25 63 | ``` 64 | 65 | An example with the ``whereOr`` method: 66 | ```php 67 | $query = (new \Girgias\QueryBuilder\Select('demo')) 68 | ->where('author', '=', 'Alice', 'author') 69 | ->whereOr('editor', '=', 'Alice', 'editor') 70 | ->getQuery(); 71 | ``` 72 | 73 | Will output: 74 | ```sql 75 | SELECT * FROM demo WHERE (author = :author OR editor = :editor) 76 | ``` 77 | 78 | ``UPDATE`` query example: 79 | ```php 80 | $query = (new \Girgias\QueryBuilder\Update('posts')) 81 | ->where('id', '=', 1, 'id') 82 | ->bindField('title', 'This is a title', 'title') 83 | ->bindField('content', 'Hello World', 'content') 84 | ->bindField('date_last_edited', (new \DateTimeImmutable()), 'nowDate') 85 | ->getQuery(); 86 | ``` 87 | 88 | Will output: 89 | ```sql 90 | UPDATE posts SET title = :title, content = :content, date_last_edited = :now_date WHERE id = :id 91 | ``` 92 | 93 | A ``SELECT`` query with an ``INNER JOIN``: 94 | ```php 95 | $query = (new \Girgias\QueryBuilder\SelectJoin('comments', 'posts')) 96 | ->tableAlias('co') 97 | ->select('co.user', 'co.content', 'p.title') 98 | ->joinTableAlias('p') 99 | ->innerJoin('post_id', 'id') 100 | ->getQuery(); 101 | ``` 102 | 103 | Will output: 104 | ```sql 105 | SELECT co.user, co.content, p.title FROM comments AS co INNER JOIN posts AS p ON comments.post_id = posts.id 106 | ``` 107 | 108 | ## Future scope 109 | 110 | Possible features that will be added to this library 111 | 112 | * WHERE subqueries 113 | 114 | ## Contributing 115 | 116 | If you found an invalid SQL name which **DOESN'T** throw a Runtime exception 117 | or a valid SQL name which does please add a test case into the 118 | ``tests/CheckSqlNamesTest.php`` file. 119 | 120 | If you found an example where this library returns an invalid SQL query 121 | please add (or fix) a test case in the relevant Query test case or if it's 122 | a general error please use the ``tests/QueryTest`` file. 123 | 124 | If a RunTime exception should be thrown please add a test in the relevant test file 125 | or if it is specific to ``SELECT`` Query please add a test in the 126 | ``tests/SelectThrowExceptionsTest.php`` file. 127 | 128 | If you'd like to contribute, please fork the repository and use a feature 129 | branch. Pull requests are warmly welcome. 130 | 131 | ### Notes 132 | When contributing please assure that Psalm runs without error 133 | and all unit tests pass. 134 | Moreover if you add functionality please add corresponding unit tests to cover 135 | at least 90% of your code and that these tests cover any edge cases if they exist. 136 | 137 | ## Links 138 | 139 | - Repository: https://github.com/girgias/query-builder/ 140 | - Issue tracker: https://github.com/girgias/query-builder/issues 141 | - In case of sensitive bugs like security vulnerabilities, please contact 142 | george.banyard@gmail.com directly instead of using the issue tracker. 143 | 144 | 145 | ## Licensing 146 | 147 | The code in this project is licensed under MIT license. 148 | -------------------------------------------------------------------------------- /.php_cs.dist: -------------------------------------------------------------------------------- 1 | setRiskyAllowed(true) 10 | ->setRules([ 11 | '@PSR1' => true, 12 | '@PSR2' => true, 13 | '@PhpCsFixer' => true, 14 | // Converts backtick operators to `shell_exec` calls. 15 | 'backtick_to_shell_exec' => true, 16 | // Comments with annotation should be docblock when used on structural elements. 17 | 'comment_to_phpdoc' => true, 18 | // Force strict types declaration in all files. 19 | // Requires PHP >= 7.0. 20 | 'declare_strict_types' => true, 21 | // Replaces `dirname(__FILE__)` expression with equivalent `__DIR__` constant. 22 | 'dir_constant' => true, 23 | // Replace deprecated `ereg` regular expression functions with `preg`. 24 | 'ereg_to_preg' => true, 25 | // Error control operator should be added to deprecation notices and/or removed from other cases. 26 | 'error_suppression' => true, 27 | // Internal classes should be `final`. 28 | 'final_internal_class' => true, 29 | // Order the flags in `fopen` calls, `b` and `t` must be last. 30 | 'fopen_flag_order' => true, 31 | // The flags in `fopen` calls must omit `t`, and `b` must be omitted or included consistently. 32 | 'fopen_flags' => true, 33 | // Replace core functions calls returning constants with the constants. 34 | 'function_to_constant' => true, 35 | // Single line comments should use double slashes `//` and not hash `#`. 36 | 'hash_to_slash_comment' => true, 37 | // Heredoc/nowdoc content must be properly indented. 38 | // Requires PHP >= 7.3. 39 | 'heredoc_indentation' => true, 40 | // Function `implode` must be called with 2 arguments in the documented order. 41 | 'implode_call' => true, 42 | // Ensure there is no code on the same line as the PHP open tag. 43 | 'linebreak_after_opening_tag' => true, 44 | // List (`array` destructuring) assignment should be declared using the configured syntax. 45 | // Requires PHP >= 7.1. 46 | 'list_syntax' => true, 47 | // Use `&&` and `||` logical operators instead of `and` and `or`. 48 | 'logical_operators' => true, 49 | // Replaces `intval`, `floatval`, `doubleval`, `strval` and `boolval` function calls with according type casting operator. 50 | 'modernize_types_casting' => true, 51 | // Add leading `\` before constant invocation of internal constant to speed up resolving. 52 | // Constant name match is case-sensitive, except for `null`, `false` and `true`. 53 | 'native_constant_invocation' => true, 54 | // Add leading `\` before function invocation to speed up resolving. 55 | 'native_function_invocation' => true, 56 | // Master functions shall be used instead of aliases. 57 | 'no_alias_functions' => true, 58 | // Replace accidental usage of homoglyphs (non ascii characters) in names. 59 | 'no_homoglyph_names' => true, 60 | // A final class must not have final methods. 61 | 'no_unneeded_final_method' => false, 62 | // Remove Zero-width space (ZWSP), Non-breaking space (NBSP) and other invisible unicode symbols. 63 | 'non_printable_character' => true, 64 | // PHPUnit assertion method calls like `->assertSame(true, $foo)` should be written with dedicated method like `->assertTrue($foo)`. 65 | 'php_unit_construct' => true, 66 | // PHPUnit assertions like `assertInternalType`, `assertFileExists`, should be used over `assertTrue`. 67 | 'php_unit_dedicate_assert' => true, 68 | // Usages of `->setExpectedException*` methods MUST be replaced by `->expectException*` methods. 69 | 'php_unit_expectation' => true, 70 | // Usages of `->getMock` and `->getMockWithoutInvokingTheOriginalConstructor` methods MUST be replaced by `->createMock` or `->createPartialMock` methods. 71 | 'php_unit_mock' => true, 72 | // PHPUnit classes MUST be used in namespaced version, eg `\PHPUnit\Framework\TestCase` instead of `\PHPUnit_Framework_TestCase`. 73 | 'php_unit_namespaced' => true, 74 | // Usages of `@expectedException*` annotations MUST be replaced by `->setExpectedException*` methods. 75 | 'php_unit_no_expectation_annotation' => true, 76 | // Changes the visibility of the `setUp()` and `tearDown()` functions of PHPUnit to `protected`, to match the PHPUnit TestCase. 77 | 'php_unit_set_up_tear_down_visibility' => true, 78 | // PHPUnit methods like `assertSame` should be used instead of `assertEquals`. 79 | 'php_unit_strict' => true, 80 | // Calls to `PHPUnit\Framework\TestCase` static methods must all be of the same type, either `$this->`, `self::` or `static::`. 81 | 'php_unit_test_case_static_method_calls' => true, 82 | // Adds a default `@coversNothing` annotation to PHPUnit test classes that have no `@covers*` annotation. 83 | 'php_unit_test_class_requires_covers' => false, 84 | // Docblocks should only be used on structural elements. 85 | 'phpdoc_to_comment' => false, 86 | // Converts `pow` to the `**` operator. 87 | 'pow_to_exponentiation' => true, 88 | // Class names should match the file name. 89 | 'psr4' => true, 90 | // Replaces `rand`, `srand`, `getrandmax` functions calls with their `mt_*` analogs. 91 | 'random_api_migration' => true, 92 | // Inside class or interface element `self` should be preferred to the class name itself. 93 | 'self_accessor' => true, 94 | // Cast shall be used, not `settype`. 95 | 'set_type_to_cast' => true, 96 | // A return statement wishing to return `void` should not return `null`. 97 | 'simplified_null_return' => true, 98 | // Lambdas not (indirect) referencing `$this` must be declared `static`. 99 | 'static_lambda' => true, 100 | // Comparisons should be strict. 101 | 'strict_comparison' => true, 102 | // Functions should be used with `$strict` param set to `true`. 103 | 'strict_param' => true, 104 | // All multi-line strings must use correct line ending. 105 | 'string_line_ending' => true, 106 | // Use `null` coalescing operator `??` where possible. 107 | // Requires PHP >= 7.0. 108 | 'ternary_to_null_coalescing' => true, 109 | // Add void return type to functions with missing or empty return statements, but priority is given to `@return` annotations. 110 | // Requires PHP >= 7.1. 111 | 'void_return' => true, 112 | // Write conditions in Yoda style (`true`), non-Yoda style (`false`) or ignore those conditions (`null`) based on configuration. 113 | 'yoda_style' => false, 114 | ]) 115 | ->setFinder(PhpCsFixer\Finder::create() 116 | ->exclude('vendor') 117 | ->in(__DIR__) 118 | ) 119 | ; 120 | -------------------------------------------------------------------------------- /tests/SelectTest.php: -------------------------------------------------------------------------------- 1 | select('title') 22 | ->getQuery() 23 | ; 24 | 25 | static::assertSame('SELECT title FROM posts', $query); 26 | } 27 | 28 | public function testQuerySelect(): void 29 | { 30 | $query = (new Select('posts'))->getQuery(); 31 | 32 | static::assertSame('SELECT * FROM posts', $query); 33 | } 34 | 35 | public function testQueryMultipleSelect(): void 36 | { 37 | $query = (new Select('posts')) 38 | ->select('title', 'category') 39 | ->getQuery() 40 | ; 41 | 42 | static::assertSame('SELECT title, category FROM posts', $query); 43 | } 44 | 45 | public function testQuerySelectAll(): void 46 | { 47 | $query = (new Select('posts')) 48 | ->selectAll() 49 | ->getQuery() 50 | ; 51 | 52 | static::assertSame('SELECT * FROM posts', $query); 53 | } 54 | 55 | public function testQuerySelectAllWithAnAliasSelect(): void 56 | { 57 | $query = (new Select('posts')) 58 | ->selectAs('title', 't') 59 | ->selectAll() 60 | ->getQuery() 61 | ; 62 | 63 | static::assertSame('SELECT *, title AS t FROM posts', $query); 64 | } 65 | 66 | public function testQuerySelectColumnAlias(): void 67 | { 68 | $query = (new Select('posts')) 69 | ->selectAs('title', 't') 70 | ->getQuery() 71 | ; 72 | 73 | static::assertSame('SELECT title AS t FROM posts', $query); 74 | } 75 | 76 | public function testQueryMultipleDistinct(): void 77 | { 78 | $query = (new Select('posts')) 79 | ->distinct('title', 'category') 80 | ->getQuery() 81 | ; 82 | 83 | static::assertSame('SELECT DISTINCT title, category FROM posts', $query); 84 | } 85 | 86 | public function testQueryDistinctColumnAlias(): void 87 | { 88 | $query = (new Select('posts')) 89 | ->distinctAs('title', 't') 90 | ->getQuery() 91 | ; 92 | 93 | static::assertSame('SELECT DISTINCT title AS t FROM posts', $query); 94 | } 95 | 96 | public function testSelectAggregate(): void 97 | { 98 | $query = (new Select('posts')) 99 | ->selectAggregate('title', AggregateFunctions::COUNT, 'nb_titles') 100 | ->getQuery() 101 | ; 102 | 103 | static::assertSame('SELECT COUNT(title) AS nb_titles FROM posts', $query); 104 | } 105 | 106 | public function testSelectAggregateAndNormalSelect(): void 107 | { 108 | $query = (new Select('posts')) 109 | ->selectAggregate('title', AggregateFunctions::COUNT, 'nb_titles') 110 | ->select('category') 111 | ->getQuery() 112 | ; 113 | 114 | static::assertSame('SELECT COUNT(title) AS nb_titles, category FROM posts', $query); 115 | } 116 | 117 | public function testSelectAggregateDistinct(): void 118 | { 119 | $query = (new Select('posts')) 120 | ->distinctAggregate('title', AggregateFunctions::COUNT, 'nb_titles') 121 | ->getQuery() 122 | ; 123 | 124 | static::assertSame('SELECT COUNT(DISTINCT title) AS nb_titles FROM posts', $query); 125 | } 126 | 127 | public function testSelectAggregateDistinctAndNormalSelect(): void 128 | { 129 | $query = (new Select('posts')) 130 | ->distinctAggregate('title', AggregateFunctions::COUNT, 'nb_titles') 131 | ->select('category') 132 | ->getQuery() 133 | ; 134 | 135 | static::assertSame('SELECT COUNT(DISTINCT title) AS nb_titles, category FROM posts', $query); 136 | } 137 | 138 | public function testQueryHavingOr(): void 139 | { 140 | $query = (new Select('demo')) 141 | ->having( 142 | 'score', 143 | AggregateFunctions::MAX, 144 | SqlOperators::MORE_THAN_OR_EQUAL, 145 | 500 146 | ) 147 | ->havingOr( 148 | 'score', 149 | AggregateFunctions::AVERAGE, 150 | SqlOperators::MORE_THAN, 151 | 200 152 | ) 153 | ->getQuery() 154 | ; 155 | 156 | static::assertSame('SELECT * FROM demo HAVING (MAX(score) >= 500 OR AVG(score) > 200)', $query); 157 | } 158 | 159 | public function testQueryHavingAndOr(): void 160 | { 161 | $query = (new Select('demo')) 162 | ->having( 163 | 'score', 164 | AggregateFunctions::MAX, 165 | SqlOperators::MORE_THAN_OR_EQUAL, 166 | 500 167 | ) 168 | ->havingOr( 169 | 'score', 170 | AggregateFunctions::AVERAGE, 171 | SqlOperators::MORE_THAN, 172 | 200 173 | ) 174 | ->having( 175 | 'game_time', 176 | AggregateFunctions::MAX, 177 | SqlOperators::LESS_THAN, 178 | 180 179 | ) 180 | ->getQuery() 181 | ; 182 | 183 | static::assertSame( 184 | 'SELECT * FROM demo HAVING (MAX(score) >= 500 OR AVG(score) > 200) AND MAX(game_time) < 180', 185 | $query 186 | ); 187 | } 188 | 189 | public function testQuerySelectWithMostMethods(): void 190 | { 191 | $query = (new Select('posts')) 192 | ->tableAlias('p') 193 | ->distinct('title') 194 | ->limit(15, 5) 195 | ->where('published', SqlOperators::EQUAL, true, 'status') 196 | ->having( 197 | 'score', 198 | AggregateFunctions::AVERAGE, 199 | SqlOperators::MORE_THAN, 200 | 20 201 | ) 202 | ->whereLike('author', 'John%', null, 'pattern') 203 | ->order('date_creation') 204 | ->group('id') 205 | ->getQuery() 206 | ; 207 | 208 | static::assertSame( 209 | 'SELECT DISTINCT title FROM posts AS p WHERE published = :status AND author LIKE :pattern '. 210 | 'GROUP BY id HAVING AVG(score) > 20 ORDER BY date_creation ASC LIMIT 15 OFFSET 5', 211 | $query 212 | ); 213 | } 214 | 215 | /** Dangerous queries */ 216 | public function testThrowExceptionOnSelectLimitWithoutOrderClause(): void 217 | { 218 | $query = (new Select('test'))->limit(5); 219 | static::expectException(DangerousSqlQueryWarning::class); 220 | $query->getQuery(); 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /tests/SelectThrowExceptionsTest.php: -------------------------------------------------------------------------------- 1 | tableAlias(self::INVALID_NAME); 31 | } 32 | 33 | /** Possible exceptions thrown SELECT columns */ 34 | public function testThrowExceptionOnInvalidSelectColumn(): void 35 | { 36 | $query = (new Select('test')); 37 | static::expectException(InvalidSqlColumnNameException::class); 38 | $query->select(self::INVALID_NAME); 39 | } 40 | 41 | public function testThrowExceptionOnInvalidSelectAsColumn(): void 42 | { 43 | $query = (new Select('test')); 44 | static::expectException(InvalidSqlColumnNameException::class); 45 | $query->selectAs(self::INVALID_NAME, 'alias'); 46 | } 47 | 48 | public function testThrowExceptionOnInvalidSelectAlias(): void 49 | { 50 | $query = (new Select('test')); 51 | static::expectException(InvalidSqlAliasNameException::class); 52 | $query->selectAs('demo', self::INVALID_NAME); 53 | } 54 | 55 | public function testThrowExceptionOnInvalidColumnWhenSelectAggregate(): void 56 | { 57 | $query = (new Select('test')); 58 | static::expectException(InvalidSqlColumnNameException::class); 59 | $query->selectAggregate(self::INVALID_NAME, AggregateFunctions::COUNT, 'alias'); 60 | } 61 | 62 | public function testThrowExceptionOnInvalidFunctionWhenSelectAggregate(): void 63 | { 64 | $query = (new Select('test')); 65 | static::expectException(UnexpectedSqlFunctionException::class); 66 | $query->selectAggregate('demo', 'not_a_function', 'alias'); 67 | } 68 | 69 | public function testThrowExceptionOnInvalidAliasWhenSelectAggregate(): void 70 | { 71 | $query = (new Select('test')); 72 | static::expectException(InvalidSqlAliasNameException::class); 73 | $query->selectAggregate('demo', AggregateFunctions::COUNT, self::INVALID_NAME); 74 | } 75 | 76 | public function testThrowExceptionOnInvalidDistinctColumn(): void 77 | { 78 | $query = (new Select('test')); 79 | static::expectException(InvalidSqlColumnNameException::class); 80 | $query->distinct(self::INVALID_NAME); 81 | } 82 | 83 | public function testThrowExceptionOnInvalidDistinctAsColumn(): void 84 | { 85 | $query = (new Select('test')); 86 | static::expectException(InvalidSqlColumnNameException::class); 87 | $query->distinctAs(self::INVALID_NAME, 'alias'); 88 | } 89 | 90 | public function testThrowExceptionOnInvalidDistinctAlias(): void 91 | { 92 | $query = (new Select('test')); 93 | static::expectException(InvalidSqlAliasNameException::class); 94 | $query->distinctAs('demo', self::INVALID_NAME); 95 | } 96 | 97 | public function testThrowExceptionOnInvalidColumnWhenDistinctAggregate(): void 98 | { 99 | $query = (new Select('test')); 100 | static::expectException(InvalidSqlColumnNameException::class); 101 | $query->distinctAggregate(self::INVALID_NAME, AggregateFunctions::COUNT, 'alias'); 102 | } 103 | 104 | public function testThrowExceptionOnInvalidFunctionWhenDistinctAggregate(): void 105 | { 106 | $query = (new Select('test')); 107 | static::expectException(UnexpectedSqlFunctionException::class); 108 | $query->distinctAggregate('demo', 'not_a_function', 'alias'); 109 | } 110 | 111 | public function testThrowExceptionOnInvalidAliasWhenDistinctAggregate(): void 112 | { 113 | $query = (new Select('test')); 114 | static::expectException(InvalidSqlAliasNameException::class); 115 | $query->distinctAggregate('demo', AggregateFunctions::COUNT, self::INVALID_NAME); 116 | } 117 | 118 | /** Possible exceptions thrown SELECT clauses */ 119 | public function testThrowExceptionOnInvalidGroupColumn(): void 120 | { 121 | $query = (new Select('test')); 122 | static::expectException(InvalidSqlColumnNameException::class); 123 | $query->group(self::INVALID_NAME); 124 | } 125 | 126 | public function testThrowExceptionOnInvalidOrderColumn(): void 127 | { 128 | $query = (new Select('test')); 129 | static::expectException(InvalidSqlColumnNameException::class); 130 | $query->order(self::INVALID_NAME); 131 | } 132 | 133 | public function testThrowExceptionOnInvalidOrderByOrder(): void 134 | { 135 | $query = (new Select('test')); 136 | static::expectException(InvalidArgumentException::class); 137 | $query->order('test', 'not a valid order'); 138 | } 139 | 140 | public function testThrowExceptionOnOutOfRangeLimit(): void 141 | { 142 | $query = (new Select('test')); 143 | static::expectException(OutOfRangeException::class); 144 | $query->limit(-1); 145 | } 146 | 147 | public function testThrowExceptionOnOutOfRangeOffset(): void 148 | { 149 | $query = (new Select('test')); 150 | static::expectException(OutOfRangeException::class); 151 | $query->limit(5, -1); 152 | } 153 | 154 | /** Possible exceptions thrown HAVING clause methods */ 155 | public function testThrowExceptionOnInvalidHavingColumn(): void 156 | { 157 | $query = (new Select('test')); 158 | static::expectException(InvalidSqlColumnNameException::class); 159 | $query->having(self::INVALID_NAME, AggregateFunctions::MAX, SqlOperators::EQUAL, 10); 160 | } 161 | 162 | public function testThrowExceptionOnUndefinedHavingOperator(): void 163 | { 164 | $query = (new Select('test')); 165 | static::expectException(UnexpectedSqlOperatorException::class); 166 | $query->having('test', AggregateFunctions::MAX, 'not an operator', 10); 167 | } 168 | 169 | public function testThrowExceptionOnUndefinedHavingFunction(): void 170 | { 171 | $query = (new Select('test')); 172 | static::expectException(UnexpectedSqlFunctionException::class); 173 | $query->having('test', 'not a function', SqlOperators::EQUAL, 10); 174 | } 175 | 176 | public function testThrowExceptionOnInvalidHavingOrColumn(): void 177 | { 178 | $query = (new Select('test')); 179 | static::expectException(InvalidSqlColumnNameException::class); 180 | $query->havingOr(self::INVALID_NAME, AggregateFunctions::MAX, SqlOperators::EQUAL, 10); 181 | } 182 | 183 | public function testThrowExceptionOnUndefinedHavingOrOperator(): void 184 | { 185 | $query = (new Select('test')); 186 | static::expectException(UnexpectedSqlOperatorException::class); 187 | $query->havingOr('test', AggregateFunctions::MAX, 'not an operator', 10); 188 | } 189 | 190 | public function testThrowExceptionOnUndefinedHavingOrFunction(): void 191 | { 192 | $query = (new Select('test')); 193 | static::expectException(UnexpectedSqlFunctionException::class); 194 | $query->havingOr('test', 'not a function', SqlOperators::EQUAL, 10); 195 | } 196 | 197 | public function testThrowExceptionWhenHavingOrCalledBeforeAnotherHavingClause(): void 198 | { 199 | $query = (new Select('test')); 200 | static::expectException(RuntimeException::class); 201 | $query->havingOr('test', AggregateFunctions::AVERAGE, SqlOperators::MORE_THAN, 200); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/Where.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | private $where; 24 | 25 | /** 26 | * Add a WHERE clause to the Query. 27 | * 28 | * @param string $column 29 | * @param string $operator 30 | * @param mixed $value 31 | * @param null|string $parameter 32 | * 33 | * @return self 34 | */ 35 | final public function where(string $column, string $operator, $value, ?string $parameter = null): self 36 | { 37 | if (!$this->isValidSqlName($column)) { 38 | throw new InvalidSqlColumnNameException('WHERE', $column); 39 | } 40 | 41 | if (!SqlOperators::isValidValue($operator)) { 42 | throw new UnexpectedSqlOperatorException('WHERE', $operator); 43 | } 44 | 45 | $parameter = $this->addStatementParameter($parameter, $value); 46 | 47 | $this->where[] = $column.' '.$operator.' :'.$parameter; 48 | 49 | return $this; 50 | } 51 | 52 | /** 53 | * Add a WHERE clause to the Query which should be ORed with the previous use of a WHERE clause. 54 | * 55 | * @param string $column 56 | * @param string $operator 57 | * @param mixed $value 58 | * @param null|string $parameter 59 | * 60 | * @return self 61 | */ 62 | final public function whereOr(string $column, string $operator, $value, ?string $parameter = null): self 63 | { 64 | if (!$this->isValidSqlName($column)) { 65 | throw new InvalidSqlColumnNameException('WHERE', $column); 66 | } 67 | 68 | if (!SqlOperators::isValidValue($operator)) { 69 | throw new UnexpectedSqlOperatorException('WHERE', $operator); 70 | } 71 | 72 | if (\is_null($this->where)) { 73 | throw new RuntimeException('Need to define at least another WHERE clause before utilizing whereOr method'); 74 | } 75 | 76 | $parameter = $this->addStatementParameter($parameter, $value); 77 | 78 | $this->where[] = '('.\array_pop($this->where).' OR '. 79 | $column.' '.$operator.' :'.$parameter.')'; 80 | 81 | return $this; 82 | } 83 | 84 | final public function whereIsNull(string $column): self 85 | { 86 | $this->where[] = $this->buildIsNullClause(self::TYPE_NORMAL, $column); 87 | 88 | return $this; 89 | } 90 | 91 | final public function whereIsNotNull(string $column): self 92 | { 93 | $this->where[] = $this->buildIsNullClause(self::TYPE_NOT, $column); 94 | 95 | return $this; 96 | } 97 | 98 | final public function whereOrIsNull(string $column): self 99 | { 100 | $this->where[] = $this->buildOrIsNullClause(self::TYPE_NORMAL, $column); 101 | 102 | return $this; 103 | } 104 | 105 | final public function whereOrIsNotNull(string $column): self 106 | { 107 | $this->where[] = $this->buildOrIsNullClause(self::TYPE_NOT, $column); 108 | 109 | return $this; 110 | } 111 | 112 | /** 113 | * Add a WHERE LIKE clause to the Query. 114 | * 115 | * @param string $column 116 | * @param string $pattern 117 | * @param null|string $escapeChar 118 | * @param null|string $namedParameter 119 | * 120 | * @return self 121 | */ 122 | final public function whereLike( 123 | string $column, 124 | string $pattern, 125 | ?string $escapeChar = null, 126 | ?string $namedParameter = null 127 | ): self { 128 | $this->where[] = $this->buildLikeClause(self::TYPE_NORMAL, $column, $pattern, $escapeChar, $namedParameter); 129 | 130 | return $this; 131 | } 132 | 133 | /** 134 | * Add a WHERE NOT LIKE clause to the Query. 135 | * 136 | * @param string $column 137 | * @param string $pattern 138 | * @param null|string $escapeChar 139 | * @param null|string $namedParameter 140 | * 141 | * @return self 142 | */ 143 | final public function whereNotLike( 144 | string $column, 145 | string $pattern, 146 | ?string $escapeChar = null, 147 | ?string $namedParameter = null 148 | ): self { 149 | $this->where[] = $this->buildLikeClause(self::TYPE_NOT, $column, $pattern, $escapeChar, $namedParameter); 150 | 151 | return $this; 152 | } 153 | 154 | /** 155 | * Add a WHERE BETWEEN clause to the Query. 156 | * 157 | * @param string $column 158 | * @param DateTimeInterface|float|int $start 159 | * @param DateTimeInterface|float|int $end 160 | * 161 | * @return self 162 | */ 163 | final public function whereBetween(string $column, $start, $end): self 164 | { 165 | $this->where[] = $this->buildBetweenClause(self::TYPE_NORMAL, $column, $start, $end); 166 | 167 | return $this; 168 | } 169 | 170 | /** 171 | * Add a WHERE NOT BETWEEN clause to the Query. 172 | * 173 | * @param string $column 174 | * @param DateTimeInterface|float|int $start 175 | * @param DateTimeInterface|float|int $end 176 | * 177 | * @return self 178 | */ 179 | final public function whereNotBetween(string $column, $start, $end): self 180 | { 181 | $this->where[] = $this->buildBetweenClause(self::TYPE_NOT, $column, $start, $end); 182 | 183 | return $this; 184 | } 185 | 186 | /** 187 | * @param string $column 188 | * @param mixed ...$values 189 | * 190 | * @return self 191 | */ 192 | final public function whereIn(string $column, ...$values): self 193 | { 194 | $this->where[] = $this->buildInClause(self::TYPE_NORMAL, $column, $values); 195 | 196 | return $this; 197 | } 198 | 199 | /** 200 | * @param string $column 201 | * @param mixed ...$values 202 | * 203 | * @return self 204 | */ 205 | final public function whereNotIn(string $column, ...$values): self 206 | { 207 | $this->where[] = $this->buildInClause(self::TYPE_NOT, $column, $values); 208 | 209 | return $this; 210 | } 211 | 212 | final protected function getWhereClause(): ?array 213 | { 214 | return $this->where; 215 | } 216 | 217 | final private function buildIsNullClause(string $type, string $column): string 218 | { 219 | if (!$this->isValidSqlName($column)) { 220 | throw new InvalidSqlColumnNameException('WHERE', $column); 221 | } 222 | 223 | return $column.' IS '.$type.'NULL'; 224 | } 225 | 226 | final private function buildOrIsNullClause(string $type, string $column): string 227 | { 228 | if (\is_null($this->where)) { 229 | throw new RuntimeException('Need to define at least another WHERE clause before utilizing whereOr method'); 230 | } 231 | 232 | return '('.\array_pop($this->where).' OR '.$this->buildIsNullClause($type, $column).')'; 233 | } 234 | 235 | final private function buildLikeClause( 236 | string $type, 237 | string $column, 238 | string $pattern, 239 | ?string $escapeChar, 240 | ?string $namedParameter 241 | ): string { 242 | if (!$this->isValidSqlName($column)) { 243 | throw new InvalidSqlColumnNameException('WHERE '.$type.'LIKE', $column); 244 | } 245 | 246 | $namedParameter = $this->addStatementParameter($namedParameter, $pattern); 247 | 248 | return $column.' '.$type.'LIKE :'.$namedParameter.$this->escape($escapeChar); 249 | } 250 | 251 | /** 252 | * @param null|string $escapeChar 253 | * 254 | * @return string 255 | */ 256 | final private function escape(?string $escapeChar): string 257 | { 258 | if (\is_null($escapeChar)) { 259 | return ''; 260 | } 261 | if (1 !== \strlen($escapeChar)) { 262 | throw new InvalidArgumentException('Escape character for LIKE clause must be of length 1'); 263 | } 264 | 265 | return ' ESCAPE \''.$escapeChar.'\''; 266 | } 267 | 268 | /** 269 | * @param string $type 270 | * @param string $column 271 | * @param mixed $start 272 | * @param mixed $end 273 | * 274 | * @return string 275 | */ 276 | final private function buildBetweenClause(string $type, string $column, $start, $end): string 277 | { 278 | if (!$this->isValidSqlName($column)) { 279 | throw new InvalidSqlColumnNameException('WHERE '.$type.'BETWEEN', $column); 280 | } 281 | 282 | if (\gettype($start) !== \gettype($end)) { 283 | throw new TypeError('Start and End values provided to WHERE '.$type.'BETWEEN are of different types'); 284 | } 285 | 286 | if (!\is_int($start) && !\is_float($start) && !($start instanceof DateTimeInterface) && 287 | !\is_int($end) && !\is_float($end) && !($end instanceof DateTimeInterface) 288 | ) { 289 | throw new InvalidArgumentException( 290 | 'Values for WHERE '.$type.'BETWEEN clause must be an integer, float or a DateTimeInterface. ' 291 | .'Input was of type:'.\gettype($start) 292 | ); 293 | } 294 | 295 | if ($start instanceof DateTimeInterface && $end instanceof DateTimeInterface) { 296 | $start = '\''.$start->format(Query::SQL_DATE_FORMAT).'\''; 297 | $end = '\''.$end->format(Query::SQL_DATE_FORMAT).'\''; 298 | } 299 | 300 | return $column.' '.$type.'BETWEEN '.(string) $start.' AND '.(string) $end; 301 | } 302 | 303 | /** 304 | * @param string $type 305 | * @param string $column 306 | * @param array $values 307 | * 308 | * @return string 309 | */ 310 | final private function buildInClause(string $type, string $column, array $values): string 311 | { 312 | if (!$this->isValidSqlName($column)) { 313 | throw new InvalidSqlColumnNameException('WHERE '.$type.'IN', $column); 314 | } 315 | 316 | if (empty($values)) { 317 | throw new \ArgumentCountError('At least one value needs to be passed to WHERE '.$type.'IN clause.'); 318 | } 319 | 320 | $parameters = []; 321 | 322 | /** 323 | * Suppressing psalm error as mixed values are expected. 324 | * 325 | * @psalm-suppress MixedAssignment 326 | */ 327 | foreach ($values as $value) { 328 | $parameters[] = ':'.$this->addStatementParameter(null, $value); 329 | } 330 | 331 | return $column.' '.$type.'IN ('.\implode(', ', $parameters).')'; 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /tests/WhereClauseTest.php: -------------------------------------------------------------------------------- 1 | where(self::INVALID_NAME, SqlOperators::EQUAL, 'random', 'random'); 29 | } 30 | 31 | public function testThrowExceptionOnUndefinedWhereOperator(): void 32 | { 33 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 34 | static::expectException(UnexpectedSqlOperatorException::class); 35 | $stub->where('test', 'not an operator', 'random', 'random'); 36 | } 37 | 38 | public function testThrowExceptionWithHelpMessageWhenNonObviousNotEqualToOperatorUsed(): void 39 | { 40 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 41 | static::expectException(UnexpectedSqlOperatorException::class); 42 | // This tests if the message is contained in the Exception message not an exact comparison. 43 | static::expectExceptionMessage('Did you mean `<>` (ANSI \'not equal to\' operator) ?'); 44 | $stub->where('test', '!=', 'random', 'random'); 45 | } 46 | 47 | public function testQueryWhereOr(): void 48 | { 49 | $query = (new Select('posts')) 50 | ->where('author', SqlOperators::EQUAL, 'Alice', 'firstAuthor') 51 | ->whereOr('author', SqlOperators::EQUAL, 'Bob', 'secondAuthor') 52 | ->getQuery() 53 | ; 54 | 55 | static::assertSame('SELECT * FROM posts WHERE (author = :firstAuthor OR author = :secondAuthor)', $query); 56 | } 57 | 58 | public function testThrowExceptionWhenWhereOrCalledBeforeAnotherWhereClause(): void 59 | { 60 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 61 | static::expectException(RuntimeException::class); 62 | $stub->whereOr('test', SqlOperators::EQUAL, 'random', 'random'); 63 | } 64 | 65 | public function testThrowExceptionOnInvalidWhereOrColumn(): void 66 | { 67 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 68 | static::expectException(InvalidSqlColumnNameException::class); 69 | $stub->whereOr(self::INVALID_NAME, SqlOperators::EQUAL, 'random', 'random'); 70 | } 71 | 72 | public function testThrowExceptionOnUndefinedWhereOrOperator(): void 73 | { 74 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 75 | static::expectException(UnexpectedSqlOperatorException::class); 76 | $stub->whereOr('test', 'not an operator', 'random', 'random'); 77 | } 78 | 79 | public function testQueryWhereAndOr(): void 80 | { 81 | $query = (new Select('posts')) 82 | ->where('author', SqlOperators::EQUAL, 'Alice', 'firstAuthor') 83 | ->whereOr('author', SqlOperators::EQUAL, 'Bob', 'secondAuthor') 84 | ->where('published', SqlOperators::EQUAL, true, 'status') 85 | ->getQuery() 86 | ; 87 | 88 | static::assertSame( 89 | 'SELECT * FROM posts WHERE (author = :firstAuthor OR author = :secondAuthor) AND published = :status', 90 | $query 91 | ); 92 | } 93 | 94 | /** 95 | * WHERE NULL TESTS. 96 | */ 97 | public function testWhereIsNull(): void 98 | { 99 | $query = (new Select('posts')) 100 | ->whereIsNull('published') 101 | ->getQuery() 102 | ; 103 | 104 | static::assertSame('SELECT * FROM posts WHERE published IS NULL', $query); 105 | } 106 | 107 | public function testWhereIsNotNull(): void 108 | { 109 | $query = (new Select('posts')) 110 | ->whereIsNotNull('published') 111 | ->getQuery() 112 | ; 113 | 114 | static::assertSame('SELECT * FROM posts WHERE published IS NOT NULL', $query); 115 | } 116 | 117 | public function testThrowExceptionOnInvalidWhereIsNullColumn(): void 118 | { 119 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 120 | static::expectException(InvalidSqlColumnNameException::class); 121 | $stub->whereIsNull(self::INVALID_NAME); 122 | } 123 | 124 | public function testWhereOrIsNull(): void 125 | { 126 | $query = (new Select('demo')) 127 | ->where('random', SqlOperators::EQUAL, 'Alice', 'random') 128 | ->whereOrIsNull('random') 129 | ->getQuery() 130 | ; 131 | 132 | static::assertSame('SELECT * FROM demo WHERE (random = :random OR random IS NULL)', $query); 133 | } 134 | 135 | public function testWhereOrIsNotNull(): void 136 | { 137 | $query = (new Select('demo')) 138 | ->where('random', SqlOperators::EQUAL, 'Alice', 'random') 139 | ->whereOrIsNotNull('random') 140 | ->getQuery() 141 | ; 142 | 143 | static::assertSame('SELECT * FROM demo WHERE (random = :random OR random IS NOT NULL)', $query); 144 | } 145 | 146 | public function testThrowExceptionWhenWhereOrNullCalledBeforeAnotherWhereClause(): void 147 | { 148 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 149 | static::expectException(RuntimeException::class); 150 | $stub->whereOrIsNull('test'); 151 | } 152 | 153 | /** 154 | * WHERE LIKE CLAUSE TESTS. 155 | */ 156 | public function testQueryWhereNotLikeWithEscapeChar(): void 157 | { 158 | $query = (new Select('posts')) 159 | ->whereNotLike('tags', '%UTF#_8', '#', 'pattern') 160 | ->getQuery() 161 | ; 162 | 163 | static::assertSame("SELECT * FROM posts WHERE tags NOT LIKE :pattern ESCAPE '#'", $query); 164 | } 165 | 166 | public function testThrowExceptionOnInvalidWhereLikeColumn(): void 167 | { 168 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 169 | static::expectException(InvalidSqlColumnNameException::class); 170 | $stub->whereLike(self::INVALID_NAME, 'a'); 171 | } 172 | 173 | public function testThrowExceptionOnInvalidWhereNotLikeColumn(): void 174 | { 175 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 176 | static::expectException(InvalidSqlColumnNameException::class); 177 | $stub->whereNotLike(self::INVALID_NAME, 'a'); 178 | } 179 | 180 | public function testThrowExceptionOnInvalidWhereLikeEscapeChar(): void 181 | { 182 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 183 | static::expectException(InvalidArgumentException::class); 184 | $stub->whereNotLike('tags', '%UTF#_8', '##'); 185 | } 186 | 187 | /** 188 | * WHERE BETWEEN CLAUSE TESTS. 189 | */ 190 | public function testQueryWhereBetweenIntegers(): void 191 | { 192 | $query = (new Select('posts')) 193 | ->whereBetween('field', 5, 10) 194 | ->getQuery() 195 | ; 196 | 197 | static::assertSame('SELECT * FROM posts WHERE field BETWEEN 5 AND 10', $query); 198 | } 199 | 200 | public function testQueryWhereBetweenDates(): void 201 | { 202 | $startDate = new \DateTime('01/01/2016'); 203 | $endDate = new \DateTime('01/01/2017'); 204 | $query = (new Select('posts')) 205 | ->whereBetween('field', $startDate, $endDate) 206 | ->getQuery() 207 | ; 208 | 209 | static::assertSame( 210 | "SELECT * FROM posts WHERE field BETWEEN '2016-01-01 00:00:00' AND '2017-01-01 00:00:00'", 211 | $query 212 | ); 213 | } 214 | 215 | public function testQueryWhereNotBetweenIntegers(): void 216 | { 217 | $query = (new Select('posts')) 218 | ->whereNotBetween('field', 5, 10) 219 | ->getQuery() 220 | ; 221 | 222 | static::assertSame('SELECT * FROM posts WHERE field NOT BETWEEN 5 AND 10', $query); 223 | } 224 | 225 | public function testQueryWhereNotBetweenDates(): void 226 | { 227 | $startDate = new \DateTime('01/01/2016'); 228 | $endDate = new \DateTime('01/01/2017'); 229 | $query = (new Select('posts')) 230 | ->whereNotBetween('field', $startDate, $endDate) 231 | ->getQuery() 232 | ; 233 | 234 | static::assertSame( 235 | "SELECT * FROM posts WHERE field NOT BETWEEN '2016-01-01 00:00:00' AND '2017-01-01 00:00:00'", 236 | $query 237 | ); 238 | } 239 | 240 | public function testThrowExceptionOnInvalidWhereBetweenColumn(): void 241 | { 242 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 243 | static::expectException(InvalidSqlColumnNameException::class); 244 | $stub->whereBetween(self::INVALID_NAME, 1, 10); 245 | } 246 | 247 | public function testThrowExceptionOnInvalidWhereNotBetweenColumn(): void 248 | { 249 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 250 | static::expectException(InvalidSqlColumnNameException::class); 251 | $stub->whereNotBetween(self::INVALID_NAME, 1, 10); 252 | } 253 | 254 | public function testThrowExceptionOnDifferentTypeWhereBetweenValues(): void 255 | { 256 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 257 | static::expectException(TypeError::class); 258 | $stub->whereBetween('demo', 1, (new \DateTime())); 259 | } 260 | 261 | public function testThrowExceptionOnDifferentTypeWhereNotBetweenValues(): void 262 | { 263 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 264 | static::expectException(TypeError::class); 265 | $stub->whereNotBetween('demo', 1, (new \DateTime())); 266 | } 267 | 268 | public function testThrowExceptionOnStringValueTypeWhereBetween(): void 269 | { 270 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 271 | static::expectException(InvalidArgumentException::class); 272 | $stub->whereBetween('demo', 'a', 'd'); 273 | } 274 | 275 | public function testThrowExceptionOnStringValueTypeWhereNotBetween(): void 276 | { 277 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 278 | static::expectException(InvalidArgumentException::class); 279 | $stub->whereNotBetween('demo', 'a', 'd'); 280 | } 281 | 282 | public function testThrowExceptionOnBoolValueTypeWhereBetween(): void 283 | { 284 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 285 | static::expectException(InvalidArgumentException::class); 286 | $stub->whereBetween('demo', true, false); 287 | } 288 | 289 | public function testThrowExceptionOnBoolValueTypeWhereNotBetween(): void 290 | { 291 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 292 | static::expectException(InvalidArgumentException::class); 293 | $stub->whereNotBetween('demo', true, false); 294 | } 295 | 296 | /** 297 | * WHERE IN CLAUSE TESTS. 298 | */ 299 | public function testWhereIn(): void 300 | { 301 | $seed = 45632; 302 | \mt_srand($seed); 303 | 304 | $query = (new Select('demo')) 305 | ->whereIn('test', 5, 10, 'hello') 306 | ->getQuery() 307 | ; 308 | 309 | static::assertSame('SELECT * FROM demo WHERE test IN (:piaxATPOGl, :KFmKzqGrEN, :TdNWVtBGr)', $query); 310 | } 311 | 312 | public function testWhereNotIn(): void 313 | { 314 | $seed = 45632; 315 | \mt_srand($seed); 316 | 317 | $query = (new Select('demo')) 318 | ->whereNotIn('test', 5, 10, 'hello') 319 | ->getQuery() 320 | ; 321 | 322 | static::assertSame('SELECT * FROM demo WHERE test NOT IN (:piaxATPOGl, :KFmKzqGrEN, :TdNWVtBGr)', $query); 323 | } 324 | 325 | public function testThrowExceptionOnInvalidWhereInColumn(): void 326 | { 327 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 328 | static::expectException(InvalidSqlColumnNameException::class); 329 | $stub->whereIn(self::INVALID_NAME, 1); 330 | } 331 | 332 | public function testThrowExceptionWithNoValuesForWhereInClause(): void 333 | { 334 | $stub = static::getMockForAbstractClass(Where::class, [], '', false); 335 | static::expectException(\ArgumentCountError::class); 336 | $stub->whereIn('demo'); 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /src/Select.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | private $having; 27 | 28 | /** 29 | * @var ?string 30 | */ 31 | private $tableAlias; 32 | 33 | /** 34 | * @var ?array 35 | */ 36 | private $select; 37 | 38 | /** 39 | * @var bool 40 | */ 41 | private $distinct = false; 42 | 43 | /** 44 | * @var ?array 45 | */ 46 | private $group; 47 | 48 | /** 49 | * @var ?array 50 | */ 51 | private $order; 52 | 53 | /** 54 | * @var ?int 55 | */ 56 | private $limit; 57 | 58 | /** 59 | * @var ?int 60 | */ 61 | private $offset; 62 | 63 | /** 64 | * Set an alias for the table. 65 | * 66 | * @param string $alias 67 | * 68 | * @return Select 69 | */ 70 | final public function tableAlias(string $alias): self 71 | { 72 | if (!$this->isValidSqlName($alias)) { 73 | throw new InvalidSqlAliasNameException('FROM', $alias); 74 | } 75 | $this->tableAlias = $alias; 76 | 77 | return $this; 78 | } 79 | 80 | /** 81 | * SELECT columns. 82 | * 83 | * @param string ...$columns 84 | * 85 | * @return Select 86 | */ 87 | final public function select(string ...$columns): self 88 | { 89 | foreach ($columns as $column) { 90 | if (!$this->isValidSqlName($column)) { 91 | throw new InvalidSqlColumnNameException('SELECT', $column); 92 | } 93 | $this->select[] = $column; 94 | } 95 | 96 | return $this; 97 | } 98 | 99 | /** 100 | * SELECT a column with an alias. 101 | * 102 | * @param string $column 103 | * @param string $alias 104 | * 105 | * @return Select 106 | */ 107 | final public function selectAs(string $column, string $alias): self 108 | { 109 | if (!$this->isValidSqlName($column)) { 110 | throw new InvalidSqlColumnNameException('SELECT', $column); 111 | } 112 | 113 | if (!$this->isValidSqlName($alias)) { 114 | throw new InvalidSqlAliasNameException($column, $alias); 115 | } 116 | 117 | $this->select[] = $column.' AS '.$alias; 118 | 119 | return $this; 120 | } 121 | 122 | /** 123 | * SELECT an aggregated column. 124 | * 125 | * @param string $column 126 | * @param string $aggregateFunction 127 | * @param string $alias 128 | * 129 | * @return Select 130 | */ 131 | final public function selectAggregate(string $column, string $aggregateFunction, string $alias): self 132 | { 133 | if (!$this->isValidSqlName($column)) { 134 | throw new InvalidSqlColumnNameException('SELECT', $column); 135 | } 136 | 137 | if (!AggregateFunctions::isValidValue($aggregateFunction)) { 138 | throw new UnexpectedSqlFunctionException('SELECT with aggregate function', $aggregateFunction); 139 | } 140 | 141 | if (!$this->isValidSqlName($alias)) { 142 | throw new InvalidSqlAliasNameException($column, $alias); 143 | } 144 | 145 | $this->select[] = $aggregateFunction.'('.$column.') AS '.$alias; 146 | 147 | return $this; 148 | } 149 | 150 | final public function selectAll(): self 151 | { 152 | if (\is_null($this->select)) { 153 | $this->select = []; 154 | } 155 | 156 | \array_unshift($this->select, '*'); 157 | 158 | return $this; 159 | } 160 | 161 | /** 162 | * SELECT DISTINCT columns. 163 | * 164 | * @param string ...$columns 165 | * 166 | * @return Select 167 | */ 168 | final public function distinct(string ...$columns): self 169 | { 170 | $this->distinct = true; 171 | 172 | return $this->select(...$columns); 173 | } 174 | 175 | /** 176 | * SELECT DISTINCT a column with an alias. 177 | * 178 | * @param string $column 179 | * @param string $alias 180 | * 181 | * @return Select 182 | */ 183 | final public function distinctAs(string $column, string $alias): self 184 | { 185 | $this->distinct = true; 186 | 187 | return $this->selectAs($column, $alias); 188 | } 189 | 190 | /** 191 | * SELECT an aggregated DISTINCT column. 192 | * 193 | * @param string $column 194 | * @param string $aggregateFunction 195 | * @param string $alias 196 | * 197 | * @return Select 198 | */ 199 | final public function distinctAggregate(string $column, string $aggregateFunction, string $alias): self 200 | { 201 | if (!$this->isValidSqlName($column)) { 202 | throw new InvalidSqlColumnNameException('DISTINCT aggregate function', $column); 203 | } 204 | 205 | if (!AggregateFunctions::isValidValue($aggregateFunction)) { 206 | throw new UnexpectedSqlFunctionException('SELECT DISTINCT with aggregate function', $aggregateFunction); 207 | } 208 | 209 | if (!$this->isValidSqlName($alias)) { 210 | throw new InvalidSqlAliasNameException($column, $alias); 211 | } 212 | 213 | $this->select[] = $aggregateFunction.'(DISTINCT '.$column.') AS '.$alias; 214 | 215 | return $this; 216 | } 217 | 218 | /** 219 | * Add a GROUP BY clause to the Query. 220 | * 221 | * @param string $column 222 | * 223 | * @return Select 224 | */ 225 | final public function group(string $column): self 226 | { 227 | if (!$this->isValidSqlName($column)) { 228 | throw new InvalidSqlColumnNameException('GROUP BY', $column); 229 | } 230 | 231 | $this->group = [$column]; 232 | 233 | return $this; 234 | } 235 | 236 | /** 237 | * Add a HAVING clause to the Query. 238 | * 239 | * @param string $column 240 | * @param string $aggregateFunction 241 | * @param string $operator 242 | * @param int $conditionValue 243 | * 244 | * @return Select 245 | */ 246 | final public function having(string $column, string $aggregateFunction, string $operator, int $conditionValue): self 247 | { 248 | if (!$this->isValidSqlName($column)) { 249 | throw new InvalidSqlColumnNameException('HAVING', $column); 250 | } 251 | 252 | if (!AggregateFunctions::isValidValue($aggregateFunction)) { 253 | throw new UnexpectedSqlFunctionException('HAVING', $aggregateFunction); 254 | } 255 | 256 | if (!SqlOperators::isValidValue($operator)) { 257 | throw new UnexpectedSqlOperatorException('HAVING', $operator); 258 | } 259 | 260 | $this->having[] = $aggregateFunction.'('.$column.') '.$operator.' '.$conditionValue; 261 | 262 | return $this; 263 | } 264 | 265 | /** 266 | * Add a HAVING clause to the Query which should be ORed with the previous use of a HAVING clause. 267 | * 268 | * @param string $column 269 | * @param string $aggregateFunction 270 | * @param string $operator 271 | * @param int $conditionValue 272 | * 273 | * @return Select 274 | */ 275 | final public function havingOr( 276 | string $column, 277 | string $aggregateFunction, 278 | string $operator, 279 | int $conditionValue 280 | ): self { 281 | if (!$this->isValidSqlName($column)) { 282 | throw new InvalidSqlColumnNameException('HAVING', $column); 283 | } 284 | 285 | if (!AggregateFunctions::isValidValue($aggregateFunction)) { 286 | throw new UnexpectedSqlFunctionException('HAVING', $aggregateFunction); 287 | } 288 | 289 | if (!SqlOperators::isValidValue($operator)) { 290 | throw new UnexpectedSqlOperatorException('HAVING', $operator); 291 | } 292 | 293 | if (\is_null($this->having)) { 294 | throw new RuntimeException( 295 | 'Need to define at least another HAVING clause before utilizing havingOr method' 296 | ); 297 | } 298 | 299 | $this->having[] = '('.\array_pop($this->having).' OR '. 300 | $aggregateFunction.'('.$column.') '.$operator.' '.$conditionValue.')'; 301 | 302 | return $this; 303 | } 304 | 305 | /** 306 | * Add an ORDER BY clause to the Query. 307 | * 308 | * @param string $column 309 | * @param string $order 310 | * 311 | * @return Select 312 | */ 313 | final public function order(string $column, string $order = self::SORT_ASC): self 314 | { 315 | if (!$this->isValidSqlName($column)) { 316 | throw new InvalidSqlColumnNameException('ORDER BY', $column); 317 | } 318 | if (self::SORT_ASC !== $order && self::SORT_DESC !== $order) { 319 | throw new InvalidArgumentException('Order must be '.self::SORT_ASC.' or '.self::SORT_DESC); 320 | } 321 | 322 | $this->order[] = $column.' '.$order; 323 | 324 | return $this; 325 | } 326 | 327 | /** 328 | * Add a LIMIT clause to the Query. 329 | * 330 | * @param int $limit 331 | * @param null|int $offset 332 | * 333 | * @return Select 334 | */ 335 | final public function limit(int $limit, ?int $offset = null): self 336 | { 337 | if ($limit < 0) { 338 | throw new OutOfRangeException('SQL LIMIT can\'t be less than 0'); 339 | } 340 | $this->limit = $limit; 341 | if (!\is_null($offset)) { 342 | $this->offset($offset); 343 | } 344 | 345 | return $this; 346 | } 347 | 348 | /** 349 | * Build SELECT query from parameters. 350 | * 351 | * @return string 352 | */ 353 | public function getQuery(): string 354 | { 355 | $parts = $this->buildBeginningSelectQuery(); 356 | 357 | \array_push($parts, ...$this->buildSqlClauses()); 358 | 359 | return \implode(' ', $parts); 360 | } 361 | 362 | final protected function buildBeginningSelectQuery(): array 363 | { 364 | if (\is_null($this->select)) { 365 | $this->select[] = '*'; 366 | } 367 | $parts = ['SELECT']; 368 | if ($this->distinct) { 369 | $parts[] = 'DISTINCT'; 370 | } 371 | $parts[] = \implode(', ', $this->select); 372 | 373 | $parts[] = 'FROM'; 374 | $parts[] = $this->getTableName(); 375 | 376 | if (!\is_null($this->tableAlias)) { 377 | $parts[] = 'AS'; 378 | $parts[] = $this->tableAlias; 379 | } 380 | 381 | return $parts; 382 | } 383 | 384 | final protected function buildSqlClauses(): array 385 | { 386 | if (!\is_null($this->limit) && \is_null($this->order)) { 387 | throw new DangerousSqlQueryWarning( 388 | 'When using LIMIT, it is important to use an ORDER BY clause that constrains the result rows '. 389 | 'into a unique order. Otherwise you will get an unpredictable subset of the query\'s rows.' 390 | ); 391 | } 392 | $clauses = []; 393 | 394 | $whereClause = $this->getWhereClause(); 395 | if (!\is_null($whereClause)) { 396 | $clauses[] = 'WHERE'; 397 | $clauses[] = \implode(' AND ', $whereClause); 398 | } 399 | 400 | if (!\is_null($this->group)) { 401 | $clauses[] = 'GROUP BY'; 402 | $clauses[] = \implode(' ', $this->group); 403 | } 404 | 405 | if (!\is_null($this->having)) { 406 | $clauses[] = 'HAVING'; 407 | $clauses[] = \implode(' AND ', $this->having); 408 | } 409 | 410 | if (!\is_null($this->order)) { 411 | $clauses[] = 'ORDER BY'; 412 | $clauses[] = \implode(', ', $this->order); 413 | } 414 | 415 | if (!\is_null($this->limit)) { 416 | $clauses[] = 'LIMIT'; 417 | $clauses[] = $this->limit; 418 | 419 | if (!\is_null($this->offset)) { 420 | $clauses[] = 'OFFSET'; 421 | $clauses[] = $this->offset; 422 | } 423 | } 424 | 425 | return $clauses; 426 | } 427 | 428 | /** 429 | * Add an OFFSET clause to the Query. 430 | * 431 | * @param int $offset 432 | */ 433 | final private function offset(int $offset): void 434 | { 435 | if ($offset < 0) { 436 | throw new OutOfRangeException('SQL OFFSET can\'t be less than 0'); 437 | } 438 | $this->offset = $offset; 439 | } 440 | } 441 | -------------------------------------------------------------------------------- /src/Enums/SqlReservedWords.php: -------------------------------------------------------------------------------- 1 |