diff --git a/CHANGELOG.md b/CHANGELOG.md index 013cbf0..206b83e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,10 @@ # Changelog -## main-dev (2024-03-17) - -- QueryBuilder and Condition/ConditionGroup now have `fromArray()` and `toArray()` methods. +## v1.0.1 (2024-04-21) -## main-dev (2024-03-25) - -- Offical docs page: https://blrf.net/dbal/ -- Full coverage -- Condtion/ConditionBuilder support for more operators (lg, lge, like, isNull, ...) - -## main-dev (2024-04-04) - -- PHPStan on max +- QueryBuilder::join() added +- sqlite driver added EXPERIMENTAL (rather old version, not production ready) +- Connection::quit() method added ## v1.0.0 (2024-04-10) @@ -23,3 +15,17 @@ - Update QueryBuilderInterface - Fix Result $rows param - Test QueryBuilder::select() with SelectExpression + +## main-dev (2024-04-04) + +- PHPStan on max + +## main-dev (2024-03-25) + +- Offical docs page: https://blrf.net/dbal/ +- Full coverage +- Condtion/ConditionBuilder support for more operators (lg, lge, like, isNull, ...) + +## main-dev (2024-03-17) + +- QueryBuilder and Condition/ConditionGroup now have `fromArray()` and `toArray()` methods. diff --git a/README.md b/README.md index ea4f5b5..fc0c906 100644 --- a/README.md +++ b/README.md @@ -120,5 +120,4 @@ MIT, see [LICENSE file](LICENSE). - Write more examples - Write having -- Write joins - Schema manager diff --git a/examples/select.php b/examples/select.php index eb46072..ec4f668 100644 --- a/examples/select.php +++ b/examples/select.php @@ -18,9 +18,11 @@ function (Blrf\Dbal\Connection $db) { $cb->eq('title') ) ) + ->orderBy('publication_date', 'DESC') ->setParameters(['9789998691568', 1, 'Moby Dick']) ->limit(3); - // sql: SELECT * FROM book WHERE ((isbn13 = ? AND language_id = ?) OR title = ?) LIMIT 3 + // sql: SELECT * FROM book WHERE ((isbn13 = ? AND language_id = ?) OR title = ?) + // ORDER BY publication_date DESC LIMIT 3 return $qb->execute(); } )->then( diff --git a/examples/selectJoin.php b/examples/selectJoin.php new file mode 100644 index 0000000..9214197 --- /dev/null +++ b/examples/selectJoin.php @@ -0,0 +1,31 @@ +create()->then( + function (Blrf\Dbal\Connection $db) { + // start query builder + $qb = $db->query() + ->select('*') + ->from('customer_address', 'address') + ->join('address_status', 'address.status_id = status.status_id', 'status') + ->where( + fn(Blrf\Dbal\Query\ConditionBuilder $cb) => $cb->and( + $cb->eq('address.customer_id'), + $cb->like('status.address_status') + ) + ) + ->setParameters([3, 'Inac%']) + ->limit(4); + echo "sql: " . $qb->getSql() . "\n"; + // sql: SELECT * FROM customer_address AS address + // INNER JOIN address_status AS status ON address.status_id = status.status_id + // WHERE (address.customer_id = ? AND status.address_status LIKE ?) LIMIT 4 + return $qb->execute(); + } +)->then( + function (Blrf\Dbal\Result $result) { + print_r($result->rows); + } +); diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 3ef127b..6cde439 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -5,6 +5,11 @@ parameters: # Condition::fromArray() will return Condition or ConditionGroup based on $data keys # don't know how to tell that to phpstan - tests/Query/ConditionTest.php + # + # SQlite driver requires additional extensions and SQLite driver + # is rather experimental. So we'll skip it for now. + # + - src/Driver/Sqlite paths: - src/ diff --git a/src/Config.php b/src/Config.php index 0f91e7d..a07b35f 100644 --- a/src/Config.php +++ b/src/Config.php @@ -32,7 +32,8 @@ class Config implements Stringable * @var array */ protected static array $driverMap = [ - 'mysql' => \Blrf\Dbal\Driver\Mysql\Driver::class + 'mysql' => \Blrf\Dbal\Driver\Mysql\Driver::class, + 'sqlite' => \Blrf\Dbal\Driver\Sqlite\Driver::class ]; /** * Dbal driver @@ -219,7 +220,7 @@ public function createDriver(): Driver { $driver = $this->driver; if (!class_exists($driver)) { - $class = self::$driverMap[$driver]; + $class = self::$driverMap[$driver] ?? null; if ($class === null) { throw new RuntimeException('No such driver: ' . $driver); } diff --git a/src/Connection.php b/src/Connection.php index e69ca4f..9f4d5bd 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -42,6 +42,13 @@ public function execute(string $sql, array $params = []): PromiseInterface; */ public function stream(string $sql, array $params = []): ResultStream; + /** + * Quit (soft-close) the connection + * + * @return PromiseInterface + */ + public function quit(): PromiseInterface; + /** * Get native connection */ diff --git a/src/Driver/Connection.php b/src/Driver/Connection.php index 70b5f32..35bf61a 100644 --- a/src/Driver/Connection.php +++ b/src/Driver/Connection.php @@ -53,6 +53,13 @@ abstract public function execute(string $sql, array $params = []): PromiseInterf */ abstract public function stream(string $sql, array $params = []): ResultStream; + /** + * Quit (soft-close) the connection + * + * @return PromiseInterface + */ + abstract public function quit(): PromiseInterface; + /** * Set underlying native connection */ diff --git a/src/Driver/Mysql/Connection.php b/src/Driver/Mysql/Connection.php index 4d749d3..b1800aa 100644 --- a/src/Driver/Mysql/Connection.php +++ b/src/Driver/Mysql/Connection.php @@ -57,4 +57,10 @@ public function stream(string $sql, array $params = []): ResultStream // @phpstan-ignore-next-line return new ResultStream($this->getNativeConnection()->queryStream($sql, $params)); } + + public function quit(): PromiseInterface + { + // @phpstan-ignore-next-line + return $this->getNativeConnection()->quit(); + } } diff --git a/src/Driver/Sqlite/Connection.php b/src/Driver/Sqlite/Connection.php new file mode 100644 index 0000000..de6d40c --- /dev/null +++ b/src/Driver/Sqlite/Connection.php @@ -0,0 +1,70 @@ +open('/' . $this->config->getDb(), SQLITE3_OPEN_READWRITE)->then( + function (DatabaseInterface $db) { + return $this->setNativeConnection($db); + } + ); + } + + public function query(): QueryBuilder + { + return new QueryBuilder($this); + } + + /** + * Execute sql query + * + * @param array $params + * @return PromiseInterface + */ + public function execute(string $sql, array $params = []): PromiseInterface + { + // @phpstan-ignore-next-line + return $this->getNativeConnection()->query($sql, $params)->then( + function (SqliteResult $res) { + return new Result( + $res->rows ?? [], + $res->insertId, + $res->changed ?? 0, + 0 + ); + } + ); + } + + public function stream(string $sql, array $params = []): ResultStream + { + throw new \Exception('Stream not yet supported on sqlite'); + } + + public function quit(): PromiseInterface + { + return $this->getNativeConnection()->quit(); + } +} diff --git a/src/Driver/Sqlite/Driver.php b/src/Driver/Sqlite/Driver.php new file mode 100644 index 0000000..f0d073e --- /dev/null +++ b/src/Driver/Sqlite/Driver.php @@ -0,0 +1,26 @@ + + */ + public function connect( + #[SensitiveParameter] + Config $config + ): PromiseInterface { + return (new Connection($config))->connect(); + } +} diff --git a/src/Driver/Sqlite/QueryBuilder.php b/src/Driver/Sqlite/QueryBuilder.php new file mode 100644 index 0000000..552fab5 --- /dev/null +++ b/src/Driver/Sqlite/QueryBuilder.php @@ -0,0 +1,28 @@ + + */ + public function execute(): PromiseInterface + { + return $this->connection->execute($this->getSql(), $this->getParameters()); + } + + public function stream(): ResultStream + { + return $this->connection->stream($this->getSql(), $this->getParameters()); + } +} diff --git a/src/Query/JoinExpression.php b/src/Query/JoinExpression.php new file mode 100644 index 0000000..53cd326 --- /dev/null +++ b/src/Query/JoinExpression.php @@ -0,0 +1,79 @@ +type = $type; + } + + public function __toString(): string + { + return $this->type->value . ' JOIN ' . $this->table . + ($this->alias === null ? '' : ' AS ' . $this->alias) . + ' ON ' . $this->on; + } + + /** @return array{ + * table: string, + * on: string, + * alias: string|null, + * type: string + * } + */ + public function toArray(): array + { + return [ + 'type' => $this->type->value, + 'table' => $this->table, + 'on' => $this->on, + 'alias' => $this->alias + ]; + } +} diff --git a/src/Query/JoinType.php b/src/Query/JoinType.php new file mode 100644 index 0000000..ee4ccaa --- /dev/null +++ b/src/Query/JoinType.php @@ -0,0 +1,11 @@ +type = $type; } diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 4356d51..1a29a3a 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -10,6 +10,8 @@ use Blrf\Dbal\Query\Limit; use Blrf\Dbal\Query\FromExpression; use Blrf\Dbal\Query\SelectExpression; +use Blrf\Dbal\Query\JoinExpression; +use Blrf\Dbal\Query\JoinType; use Blrf\Dbal\Query\OrderByExpression; use Blrf\Dbal\Query\OrderByType; use Blrf\Dbal\Query\Type; @@ -27,6 +29,8 @@ * Each Driver defines it's own QueryBuilder which may override any method to implement it's own SQL dialect. * * NOTE: It will not validate queries. + * + * @phpstan-import-type JoinFromArray from JoinExpression */ class QueryBuilder implements QueryBuilderInterface { @@ -47,6 +51,11 @@ class QueryBuilder implements QueryBuilderInterface * @var array */ protected array $columns = []; + /** + * Joins + * @var array + */ + protected array $joins = []; protected Condition|ConditionGroup|null $where = null; /** * Order by expressions @@ -118,6 +127,7 @@ class QueryBuilder implements QueryBuilderInterface * type?: string|Type, * select?: array|string, * from?: array|string, + * join?: array, * columns?: array, * where?:array|null, * values?:array, @@ -175,6 +185,17 @@ public static function fromArray(array $data, mixed ...$arguments): QueryBuilder $qb->addFromExpression(FromExpression::fromString($data['from'])); } } + + /** + * JOIN expressions + */ + if (isset($data['join']) && is_array($data['join'])) { + foreach ($data['join'] as $join) { + if (is_array($join)) { + $qb->addJoinExpression(JoinExpression::fromArray($join)); + } + } + } $qb->columns = $data['columns'] ?? []; if (isset($data['where']) && is_array($data['where'])) { $qb->where = Condition::fromArray($data['where']); @@ -235,6 +256,7 @@ public function toArray(): array 'type' => $this->type->value, 'select' => array_map(fn($expr) => $expr->toArray(), $this->select), 'from' => array_map(fn($expr) => $expr->toArray(), $this->from), + 'join' => array_map(fn($expr) => $expr->toArray(), $this->joins), 'columns' => $this->columns, 'where' => $this->where === null ? null : $this->where->toArray(), 'orderBy' => array_map(fn($expr) => $expr->toArray(), $this->orderBy), @@ -362,6 +384,26 @@ public function set(array $values): static return $this->values($values); } + public function join(string $table, string $on, string $alias = null, JoinType $type = JoinType::INNER): static + { + return $this->addJoinExpression($this->createJoinExpression($table, $on, $alias, $type)); + } + + public function leftJoin(string $table, string $on, string $alias = null): static + { + return $this->addJoinExpression($this->createJoinExpression($table, $on, $alias, JoinType::LEFT)); + } + + public function rightJoin(string $table, string $on, string $alias = null): static + { + return $this->addJoinExpression($this->createJoinExpression($table, $on, $alias, JoinType::RIGHT)); + } + + public function fullJoin(string $table, string $on, string $alias = null): static + { + return $this->addJoinExpression($this->createJoinExpression($table, $on, $alias, JoinType::FULL)); + } + /** * Start condition builder or create simple condition * @@ -477,6 +519,7 @@ public function getParameters(): array * * - self::getSqlPartSelect() * - self::getSqlPartFrom() + * - self::getSqlPartJoin() * - self::getSqlPartWhere() * - self::getSqlPartOrderBy() * - self::getSqlPartLimit() @@ -508,6 +551,7 @@ public function getSql(): string return $this->type->value . $this->getSqlPartSelect() . $this->getSqlPartFrom() . + $this->getSqlPartJoin() . $this->getSqlPartWhere() . $this->getSqlPartOrderBy() . $this->getSqlPartLimit(); @@ -569,6 +613,11 @@ protected function getSqlPartFrom(): string return empty($this->from) ? '' : ' FROM ' . implode(', ', $this->from); } + protected function getSqlPartJoin(): string + { + return empty($this->joins) ? '' : ' ' . implode(' ', array_map(fn($join) => (string)$join, $this->joins)); + } + /** * Into expressions */ @@ -670,6 +719,21 @@ public function addFromExpression(FromExpression $expr): static return $this; } + protected function createJoinExpression( + string $table, + string $on, + string $alias = null, + JoinType $type = JoinType::INNER + ): JoinExpression { + return new JoinExpression($type, $table, $on, $alias); + } + + public function addJoinExpression(JoinExpression $expr): static + { + $this->joins[] = $expr; + return $this; + } + public function createOrderByExpression( string $expr, OrderByType|string $type = 'ASC' diff --git a/src/QueryBuilderInterface.php b/src/QueryBuilderInterface.php index a38342e..7d092fa 100644 --- a/src/QueryBuilderInterface.php +++ b/src/QueryBuilderInterface.php @@ -7,7 +7,10 @@ use Blrf\Dbal\Query\Condition; use Blrf\Dbal\Query\ConditionGroup; use Blrf\Dbal\Query\FromExpression; +use Blrf\Dbal\Query\JoinExpression; +use Blrf\Dbal\Query\JoinType; use Blrf\Dbal\Query\OrderByExpression; +use Blrf\Dbal\Query\OrderByType; use Blrf\Dbal\Query\SelectExpression; interface QueryBuilderInterface @@ -31,6 +34,14 @@ public function from(string|FromExpression|self $from, string $as = null): stati public function addFromExpression(FromExpression $expr): static; + public function join(string $table, string $on, string $alias = null, JoinType $type = JoinType::INNER): static; + + public function leftJoin(string $table, string $on, string $alias = null): static; + public function rightJoin(string $table, string $on, string $alias = null): static; + public function fullJoin(string $table, string $on, string $alias = null): static; + + public function addJoinExpression(JoinExpression $expr): static; + public function value(string $column, mixed $value): static; /** @@ -46,7 +57,7 @@ public function andWhere(Condition|ConditionGroup|callable $condition): static; public function orWhere(Condition|ConditionGroup|callable $condition): static; - public function orderBy(string $orderBy, string $type = 'ASC'): static; + public function orderBy(string $orderBy, OrderByType|string $type = 'ASC'): static; public function addOrderByExpression(OrderByExpression $expr): static; diff --git a/tests/Query/JoinExpressionTest.php b/tests/Query/JoinExpressionTest.php new file mode 100644 index 0000000..adac03b --- /dev/null +++ b/tests/Query/JoinExpressionTest.php @@ -0,0 +1,66 @@ +expectException(\ValueError::class); + new JoinExpression(JoinType::INNER, '', ''); + } + + public function testConstructWithEmptyOnThrowsValueError(): void + { + $this->expectException(\ValueError::class); + new JoinExpression(JoinType::INNER, 'table', ''); + } + + public function testConstructDefaultTypeIsInner(): void + { + $expr = new JoinExpression('', 'table', 'on'); + $this->assertSame($expr->type, JoinType::INNER); + } + + public function testFromStringNotImplemented(): void + { + $this->expectException(\Exception::class); + $exp = JoinExpression::fromString(''); + } + + public function testFromArrayAndToArrayAndToString(): void + { + $expr = JoinExpression::fromArray([ + 'type' => JoinType::FULL, + 'table' => 'table', + 'on' => 'on', + 'alias' => 'alias' + ]); + $this->assertSame(JoinType::FULL, $expr->type); + $this->assertSame('table', $expr->table); + $this->assertSame('on', $expr->on); + $this->assertSame('alias', $expr->alias); + + $exp = [ + 'type' => JoinType::FULL->value, + 'table' => 'table', + 'on' => 'on', + 'alias' => 'alias' + ]; + + $this->assertSame($exp, $expr->toArray()); + $this->assertSame('FULL JOIN table AS alias ON on', $expr->__toString()); + } + + public function testToStringWithoutAlias():void + { + $expr = new JoinExpression(JoinType::LEFT, 'table', 'on'); + $this->assertSame('LEFT JOIN table ON on', $expr->__toString()); + } +} diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php index 51ba638..0027eb2 100644 --- a/tests/QueryBuilderTest.php +++ b/tests/QueryBuilderTest.php @@ -22,6 +22,7 @@ public function testEmptyQueryBuilder(): void 'type' => 'SELECT', 'select' => [], 'from' => [], + 'join' => [], 'columns' => [], 'where' => null, 'orderBy' => [], @@ -36,25 +37,98 @@ public function testEmptyQueryBuilder(): void public function testSelect(): void { - $exp = 'SELECT a,b FROM c WHERE d = ? ORDER BY e ASC LIMIT 1 OFFSET 2'; + $exp = 'SELECT a,b FROM c INNER JOIN d AS e ON c.id = e.id WHERE f = ? ORDER BY f ASC LIMIT 1 OFFSET 2'; $qb = new QueryBuilder(); $qb ->select('a', 'b') ->from('c') + ->join('d', 'c.id = e.id', 'e') ->where( - fn($b) => $b->eq('d') + fn($b) => $b->eq('f') ) - ->orderBy('e') + ->orderBy('f') ->limit(1, 2) - ->setParameters(['f']); + ->setParameters(['h']); $this->assertSame( $exp, $qb->getSql() ); - $this->assertSame(['f'], $qb->getParameters()); + $this->assertSame(['h'], $qb->getParameters()); $nqb = QueryBuilder::fromArray($qb->toArray()); $this->assertSame($exp, $nqb->getSql()); - $this->assertSame(['f'], $nqb->getParameters()); + $this->assertSame(['h'], $nqb->getParameters()); + } + + public function testSelectLeftJoin(): void + { + $exp = 'SELECT a,b FROM c LEFT JOIN d AS e ON c.id = e.id WHERE f = ? ORDER BY f ASC LIMIT 1 OFFSET 2'; + $qb = new QueryBuilder(); + $qb + ->select('a', 'b') + ->from('c') + ->leftJoin('d', 'c.id = e.id', 'e') + ->where( + fn($b) => $b->eq('f') + ) + ->orderBy('f') + ->limit(1, 2) + ->setParameters(['h']); + $this->assertSame( + $exp, + $qb->getSql() + ); + $this->assertSame(['h'], $qb->getParameters()); + $nqb = QueryBuilder::fromArray($qb->toArray()); + $this->assertSame($exp, $nqb->getSql()); + $this->assertSame(['h'], $nqb->getParameters()); + } + + public function testSelectRightJoinWithoutAlias(): void + { + $exp = 'SELECT a,b FROM c RIGHT JOIN d ON c.id = d.id WHERE f = ? ORDER BY f ASC LIMIT 1 OFFSET 2'; + $qb = new QueryBuilder(); + $qb + ->select('a', 'b') + ->from('c') + ->rightJoin('d', 'c.id = d.id') + ->where( + fn($b) => $b->eq('f') + ) + ->orderBy('f') + ->limit(1, 2) + ->setParameters(['h']); + $this->assertSame( + $exp, + $qb->getSql() + ); + $this->assertSame(['h'], $qb->getParameters()); + $nqb = QueryBuilder::fromArray($qb->toArray()); + $this->assertSame($exp, $nqb->getSql()); + $this->assertSame(['h'], $nqb->getParameters()); + } + + public function testSelectFullJoin(): void + { + $exp = 'SELECT a,b FROM c FULL JOIN d AS e ON c.id = e.id WHERE f = ? ORDER BY f ASC LIMIT 1 OFFSET 2'; + $qb = new QueryBuilder(); + $qb + ->select('a', 'b') + ->from('c') + ->fullJoin('d', 'c.id = e.id', 'e') + ->where( + fn($b) => $b->eq('f') + ) + ->orderBy('f') + ->limit(1, 2) + ->setParameters(['h']); + $this->assertSame( + $exp, + $qb->getSql() + ); + $this->assertSame(['h'], $qb->getParameters()); + $nqb = QueryBuilder::fromArray($qb->toArray()); + $this->assertSame($exp, $nqb->getSql()); + $this->assertSame(['h'], $nqb->getParameters()); } public function testSelectWithExpression(): void