From 2fe8b09e95c0ca6ca4203049f890d1c4ba28a91e Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Wed, 9 Apr 2025 11:39:13 +0200 Subject: [PATCH 1/6] use promoted properties --- src/Query/Select.php | 49 ++++++++++++++------------------------------ 1 file changed, 15 insertions(+), 34 deletions(-) diff --git a/src/Query/Select.php b/src/Query/Select.php index ebddfbc..38fcf43 100644 --- a/src/Query/Select.php +++ b/src/Query/Select.php @@ -24,22 +24,6 @@ */ final class Select implements Query { - private Name|Name\Aliased $table; - private bool $lazy; - /** @var Sequence */ - private Sequence $joins; - /** @var Sequence */ - private Sequence $columns; - /** @var Maybe */ - private Maybe $count; - private Where $where; - /** @var ?array{Column\Name|Column\Name\Namespaced|Column\Name\Aliased, Direction} */ - private ?array $orderBy; - /** @var ?positive-int */ - private ?int $limit; - /** @var ?positive-int */ - private ?int $offset; - /** * @param Sequence $joins * @param Sequence $columns @@ -49,25 +33,16 @@ final class Select implements Query * @param ?positive-int $offset */ private function __construct( - Name|Name\Aliased $table, - bool $lazy, - Sequence $joins, - Sequence $columns, - Maybe $count, - Where $where, - ?array $orderBy = null, - ?int $limit = null, - ?int $offset = null, + private Name|Name\Aliased $table, + private bool $lazy, + private Sequence $joins, + private Sequence $columns, + private Maybe $count, + private Where $where, + private ?array $orderBy, + private ?int $limit, + private ?int $offset, ) { - $this->table = $table; - $this->lazy = $lazy; - $this->joins = $joins; - $this->columns = $columns; - $this->count = $count; - $this->where = $where; - $this->orderBy = $orderBy; - $this->limit = $limit; - $this->offset = $offset; } /** @@ -85,6 +60,9 @@ public static function from(Name|Name\Aliased $table): self Sequence::of(), $count, Where::everything(), + null, + null, + null, ); } @@ -103,6 +81,9 @@ public static function onDemand(Name|Name\Aliased $table): self Sequence::of(), $count, Where::everything(), + null, + null, + null, ); } From b498b277723dc096b2bc02e520855b1f6d30dc17 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Wed, 9 Apr 2025 15:08:33 +0200 Subject: [PATCH 2/6] allow to select row values --- CHANGELOG.md | 6 + composer.json | 2 +- properties/Connection.php | 1 + properties/Connection/SelectValues.php | 150 +++++++++++++++++++++++++ src/Query/Select.php | 27 +++-- 5 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 properties/Connection/SelectValues.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3abc4fa..c75d2da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Added + +- `Formal\Access\Layer\Query\Select::columns()` now accepts row values + ## 4.1.0 - 2025-03-21 ### Added diff --git a/composer.json b/composer.json index 5ce5473..f79ac9a 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,7 @@ }, "require-dev": { "innmind/static-analysis": "^1.2.1", - "innmind/black-box": "^5.6.1|^6.0.2", + "innmind/black-box": "^5.8|^6.0.2", "innmind/coding-standard": "~2.0" }, "conflict": { diff --git a/properties/Connection.php b/properties/Connection.php index 4622c1f..60d43ce 100644 --- a/properties/Connection.php +++ b/properties/Connection.php @@ -71,6 +71,7 @@ public static function list(): array Connection\SelectOffset::class, Connection\SelectLimit::class, Connection\SelectOrder::class, + Connection\SelectValues::class, Connection\Update::class, Connection\UpdateSpecificRow::class, Connection\Delete::class, diff --git a/properties/Connection/SelectValues.php b/properties/Connection/SelectValues.php new file mode 100644 index 0000000..90ea067 --- /dev/null +++ b/properties/Connection/SelectValues.php @@ -0,0 +1,150 @@ + + */ +final class SelectValues implements Property +{ + private function __construct( + private string $uuid, + private string $username, + private int $number, + private $valueName, + private int|string|bool|null $value, + ) { + } + + public static function any(): Set + { + return Set\Composite::immutable( + static fn(...$args) => new self(...$args), + Set\Uuid::any(), + Set\Strings::madeOf(Set\Chars::ascii())->between(0, 255), + Set\Integers::any(), + FName::any(), + Set\Either::any( + Set\Integers::any(), + Set\Strings::madeOf(Set\Unicode::any()), + Set\Elements::of(null, true, false), + ), + ); + } + + public function applicableTo(object $connection): bool + { + return true; + } + + public function ensureHeldBy(Assert $assert, object $connection): object + { + $connection(Insert::into( + Name::of('test'), + Row::of([ + 'id' => $this->uuid, + 'username' => $this->username, + 'registerNumber' => $this->number, + ]), + )); + + $select = Select::from(Name::of('test')) + ->columns( + Column\Name::of('id'), + Column\Name::of('username'), + Column\Name::of('registerNumber'), + Row\Value::of( + $this->valueName, + $this->value, + ), + ) + ->where(Comparator\Property::of( + 'id', + Sign::equality, + $this->uuid, + )); + $rows = $connection($select); + + $assert->count(1, $rows); + $assert->same( + $this->uuid, + $rows + ->first() + ->flatMap(static fn($row) => $row->column('id')) + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + $assert->same( + $this->username, + $rows + ->first() + ->flatMap(static fn($row) => $row->column('username')) + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + $assert->same( + $this->number, + $rows + ->first() + ->flatMap(static fn($row) => $row->column('registerNumber')) + ->match( + static fn($value) => $value, + static fn() => null, + ), + ); + + $value = $rows + ->first() + ->flatMap(fn($row) => $row->column($this->valueName->toString())) + ->match( + static fn($value) => $value, + static fn() => null, + ); + + // Custom assertions here due to the way the different drivers interpret + // them and return them. + // Since selecting inline values should be used in an insert query the + // drivers should handle casting values to the correct types internally. + if ($this->value === true) { + $assert + ->array([1, 't']) + ->contains($value); + } else if ($this->value === false) { + $assert + ->array([0, 'f']) + ->contains($value); + } else if (\is_int($this->value)) { + $assert + ->array([$this->value, (string) $this->value]) + ->contains($value); + } else { + $assert->same($this->value, $value); + } + + return $connection; + } +} diff --git a/src/Query/Select.php b/src/Query/Select.php index 38fcf43..d1830b8 100644 --- a/src/Query/Select.php +++ b/src/Query/Select.php @@ -9,6 +9,7 @@ Query\Select\Join, Table\Name, Table\Column, + Row, Driver, }; use Innmind\Specification\Specification; @@ -17,6 +18,7 @@ Str, Monoid\Concat, Maybe, + Predicate\Instance, }; /** @@ -26,7 +28,7 @@ final class Select implements Query { /** * @param Sequence $joins - * @param Sequence $columns + * @param Sequence $columns * @param Maybe $count * @param ?array{Column\Name|Column\Name\Namespaced|Column\Name\Aliased, Direction} $orderBy * @param ?positive-int $limit @@ -106,8 +108,8 @@ public function join(Join $join): self * @no-named-arguments */ public function columns( - Column\Name|Column\Name\Namespaced|Column\Name\Aliased $first, - Column\Name|Column\Name\Namespaced|Column\Name\Aliased ...$rest, + Column\Name|Column\Name\Namespaced|Column\Name\Aliased|Row\Value $first, + Column\Name|Column\Name\Namespaced|Column\Name\Aliased|Row\Value ...$rest, ): self { /** @var Maybe */ $count = Maybe::nothing(); @@ -197,7 +199,14 @@ public function limit(int $limit, ?int $offset = null): self #[\Override] public function parameters(): Sequence { - return $this->where->parameters(); + return $this + ->columns + ->keep(Instance::of(Row\Value::class)) + ->map(static fn($value) => Parameter::of( + $value->value(), + $value->type(), + )) + ->append($this->where->parameters()); } #[\Override] @@ -248,9 +257,13 @@ public function lazy(): bool private function buildColumns(Driver $driver): string { - $columns = $this->columns->map( - static fn($column) => $column->sql($driver), - ); + $columns = $this->columns->map(static fn($column) => match (true) { + $column instanceof Row\Value => \sprintf( + '? as %s', + $column->column()->sql($driver), + ), + default => $column->sql($driver), + }); /** @psalm-suppress InvalidArgument Because non-empty-string instead of string */ return Str::of(', ')->join($columns)->toString(); From 0b13128d36d0b9f0dbbaa3a9e59e16441032d99a Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Wed, 9 Apr 2025 15:20:16 +0200 Subject: [PATCH 3/6] use promoted properties --- src/Query/Insert.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Query/Insert.php b/src/Query/Insert.php index dd888ff..f63c5ea 100644 --- a/src/Query/Insert.php +++ b/src/Query/Insert.php @@ -19,13 +19,10 @@ */ final class Insert implements Query { - private Name $table; - private Row $row; - - private function __construct(Name $table, Row $row) - { - $this->table = $table; - $this->row = $row; + private function __construct( + private Name $table, + private Row $row, + ) { } /** From e333d93819da106082e300cc1de2907cad2b5b66 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Wed, 9 Apr 2025 15:45:59 +0200 Subject: [PATCH 4/6] allow to insert values via a select query --- CHANGELOG.md | 1 + properties/Connection.php | 1 + properties/Connection/InsertSelect.php | 124 +++++++++++++++++++++++++ src/Query/Insert.php | 25 ++++- src/Query/Select.php | 20 ++++ 5 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 properties/Connection/InsertSelect.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c75d2da..4f9cca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - `Formal\Access\Layer\Query\Select::columns()` now accepts row values +- `Formal\Access\Layer\Query\Insert::into()` now accepts a `Select` query to specify the rows to insert ## 4.1.0 - 2025-03-21 diff --git a/properties/Connection.php b/properties/Connection.php index 60d43ce..62f5493 100644 --- a/properties/Connection.php +++ b/properties/Connection.php @@ -42,6 +42,7 @@ public static function list(): array Connection\AQueryWithoutTheCorrectNumberOfParametersMustThrow::class, Connection\MustThrowWhenValueDoesntFitTheSchema::class, Connection\Insert::class, + Connection\InsertSelect::class, Connection\MultipleInsertsAtOnce::class, Connection\ParametersCanBeBoundByName::class, Connection\ParametersCanBeBoundByIndex::class, diff --git a/properties/Connection/InsertSelect.php b/properties/Connection/InsertSelect.php new file mode 100644 index 0000000..73de92f --- /dev/null +++ b/properties/Connection/InsertSelect.php @@ -0,0 +1,124 @@ + + */ +final class InsertSelect implements Property +{ + private function __construct( + private string $uuid, + private string $username, + private int $number, + private string $value, + ) { + } + + public static function any(): Set + { + return Set\Composite::immutable( + static fn(...$args) => new self(...$args), + Set\Uuid::any(), + Set\Strings::madeOf(Set\Chars::alphanumerical())->between(0, 100), + Set\Integers::any(), + Set\Strings::madeOf(Set\Chars::alphanumerical())->between(10, 100), // to avoid collisions + ); + } + + public function applicableTo(object $connection): bool + { + return true; + } + + public function ensureHeldBy(Assert $assert, object $connection): object + { + $select = SQL::of("SELECT * FROM test_values WHERE id = '{$this->uuid}'"); + $rows = $connection($select); + + $assert->count(0, $rows); + + $sequence = $connection(Query\Insert::into( + Table\Name::of('test'), + Row::of([ + 'id' => $this->uuid, + 'username' => $this->username, + 'registerNumber' => $this->number, + ]), + )); + + $assert->count(0, $sequence); + + $sequence = $connection(Query\Insert::into( + Table\Name::of('test_values'), + Query\Select::from(Table\Name::of('test')) + ->columns( + Row\Value::of( + Table\Column\Name::of('value'), + $this->value, + ), + Table\Column\Name::of('id'), + ) + ->where(Comparator\Property::of( + 'id', + Sign::equality, + $this->uuid, + )), + )); + + $assert->count(0, $sequence); + + $rows = $connection($select); + + $assert->count(1, $rows); + $assert->same( + $this->uuid, + $rows + ->first() + ->flatMap(static fn($row) => $row->column('id')) + ->match( + static fn($id) => $id, + static fn() => null, + ), + ); + $assert->same( + $this->value, + $rows + ->first() + ->flatMap(static fn($row) => $row->column('value')) + ->match( + static fn($username) => $username, + static fn() => null, + ), + ); + + $assert + ->array( + $connection(SQL::of("SELECT * FROM test_values WHERE id <> '{$this->uuid}'")) + ->flatMap(static fn($row) => $row->column('value')->toSequence()) + ->toList(), + ) + ->not() + ->contains($this->value); + + return $connection; + } +} diff --git a/src/Query/Insert.php b/src/Query/Insert.php index f63c5ea..f7a4d68 100644 --- a/src/Query/Insert.php +++ b/src/Query/Insert.php @@ -21,14 +21,14 @@ final class Insert implements Query { private function __construct( private Name $table, - private Row $row, + private Row|Select $row, ) { } /** * @psalm-pure */ - public static function into(Name $table, Row $row): self + public static function into(Name $table, Row|Select $row): self { return new self($table, $row); } @@ -36,6 +36,10 @@ public static function into(Name $table, Row $row): self #[\Override] public function parameters(): Sequence { + if ($this->row instanceof Select) { + return $this->row->parameters(); + } + return $this->row->values()->map( static fn($value) => Parameter::of($value->value(), $value->type()), ); @@ -58,6 +62,23 @@ public function lazy(): bool */ private function buildInsert(Driver $driver): string { + if ($this->row instanceof Select) { + $columns = $this->row->names(); + + if ($columns->empty()) { + throw new \LogicException('You need to specify the columns to select when inserting'); + } + + $keys = $columns->map(static fn($column) => $column->sql($driver)); + + return \sprintf( + 'INSERT INTO %s (%s) %s', + $this->table->sql($driver), + Str::of(', ')->join($keys)->toString(), + $this->row->sql($driver), + ); + } + /** @var Sequence */ $keys = $this->row->values()->map(static fn($value) => $value->columnSql($driver)); /** @var Sequence */ diff --git a/src/Query/Select.php b/src/Query/Select.php index d1830b8..1ed79b1 100644 --- a/src/Query/Select.php +++ b/src/Query/Select.php @@ -255,6 +255,26 @@ public function lazy(): bool return $this->lazy; } + /** + * @internal + * + * @return Sequence + */ + public function names(): Sequence + { + return $this->count->match( + static fn($alias) => Sequence::of(Column\Name::of($alias)), + fn() => $this->columns->map(static fn($column) => match (true) { + $column instanceof Row\Value => $column->column(), + $column instanceof Column\Name\Aliased => Column\Name::of( + $column->alias(), + ), + $column instanceof Column\Name\Namespaced => $column->column(), + default => $column, + }), + ); + } + private function buildColumns(Driver $driver): string { $columns = $this->columns->map(static fn($column) => match (true) { From 58d3c709e819a008929d61b4e2bade9c33a7c6b5 Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Wed, 9 Apr 2025 16:12:19 +0200 Subject: [PATCH 5/6] add documentation --- documentation/queries/insert.md | 24 ++++++++++++++++++++++++ documentation/queries/select.md | 25 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/documentation/queries/insert.md b/documentation/queries/insert.md index cff8da5..f53c747 100644 --- a/documentation/queries/insert.md +++ b/documentation/queries/insert.md @@ -81,3 +81,27 @@ $connection($insert(Sequence::of( ``` !!! warning "" Each `Row` must specify the same amount of columns and in the same order, otherwise it will fail. + +## Select insert + +`Insert` allows you to insert multiple values at once coming from another table via a `Select` query. + +```php +use Formal\AccessLayer\{ + Query\Insert, + Query\Select, + Table\Name, + Table\Column, +}; +$insert = Insert::into( + Name::of('users'), + Select::from('leads')->columns( + Column\Name::of('username'), + Column\Name::of('name'), + ), +); + +$connection($insert); +``` + +This example copies all `leads` as new `users`. diff --git a/documentation/queries/select.md b/documentation/queries/select.md index d8d2477..f937411 100644 --- a/documentation/queries/select.md +++ b/documentation/queries/select.md @@ -68,3 +68,28 @@ $users = $connection($select); The property name can include the name of the table to match by using the format `'{table}.{column}'`. The value of the specification can also be a query (this will translated to a sub query). + +## Select inline values + +On top of specifying the columns to fetch from the table you can specify values as virtual columns. This is very useful when using a `Select` to [insert multiple values at once](insert.md#select-insert). + +```php +use Formal\AccesLayer\{ + Query\Select, + Table\Name, + Table\Column, + Row, +}; + +$select = Select::from(Name::of('users'))->columns( + Column\Name::of('id'), + Row\Value::of( + Column\Name::of('score'), + 0, + ), +); +``` + +This will return as many rows as you have users and with a `0` in the `score` column. + +You could use this query to populate a new table `users_score`. From 0a09a1815ccb6709e57145283c8f88e4c4f3983d Mon Sep 17 00:00:00 2001 From: Baptiste Langlade Date: Wed, 9 Apr 2025 16:36:47 +0200 Subject: [PATCH 6/6] reduce the scope of tested inline strings in selects --- documentation/queries/select.md | 5 +++++ properties/Connection/SelectValues.php | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/documentation/queries/select.md b/documentation/queries/select.md index f937411..786ef82 100644 --- a/documentation/queries/select.md +++ b/documentation/queries/select.md @@ -93,3 +93,8 @@ $select = Select::from(Name::of('users'))->columns( This will return as many rows as you have users and with a `0` in the `score` column. You could use this query to populate a new table `users_score`. + +??? warning + PostgreSQL is more strict than MySQL when specifying inline strings. It will fail when it deems strings to not be valid utf8 strings. + + Initially this feature was tested against any [unicode string](https://innmind.org/BlackBox/sets/#__tabbed_2_3) but PostgreSQL would regularly fail on some of them. It may only be some character blocks that cause problems. But for the time being it's now only tested on alphanumerical characters. diff --git a/properties/Connection/SelectValues.php b/properties/Connection/SelectValues.php index 90ea067..19d7d54 100644 --- a/properties/Connection/SelectValues.php +++ b/properties/Connection/SelectValues.php @@ -46,7 +46,7 @@ public static function any(): Set FName::any(), Set\Either::any( Set\Integers::any(), - Set\Strings::madeOf(Set\Unicode::any()), + Set\Strings::madeOf(Set\Chars::alphanumerical()), Set\Elements::of(null, true, false), ), );