├── .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 | [](https://travis-ci.org/Girgias/query-builder)
3 | [](https://codeclimate.com/github/Girgias/query-builder/maintainability)
4 | [](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 |