diff --git a/CHANGELOG.md b/CHANGELOG.md index 3abc4fa..4f9cca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [Unreleased] + +### 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 ### 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/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..786ef82 100644 --- a/documentation/queries/select.md +++ b/documentation/queries/select.md @@ -68,3 +68,33 @@ $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`. + +??? 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.php b/properties/Connection.php index 4622c1f..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, @@ -71,6 +72,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/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/properties/Connection/SelectValues.php b/properties/Connection/SelectValues.php new file mode 100644 index 0000000..19d7d54 --- /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\Chars::alphanumerical()), + 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/Insert.php b/src/Query/Insert.php index dd888ff..f7a4d68 100644 --- a/src/Query/Insert.php +++ b/src/Query/Insert.php @@ -19,19 +19,16 @@ */ 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|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); } @@ -39,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()), ); @@ -61,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 ebddfbc..1ed79b1 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, }; /** @@ -24,50 +26,25 @@ */ 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 + * @param Sequence $columns * @param Maybe $count * @param ?array{Column\Name|Column\Name\Namespaced|Column\Name\Aliased, Direction} $orderBy * @param ?positive-int $limit * @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 +62,9 @@ public static function from(Name|Name\Aliased $table): self Sequence::of(), $count, Where::everything(), + null, + null, + null, ); } @@ -103,6 +83,9 @@ public static function onDemand(Name|Name\Aliased $table): self Sequence::of(), $count, Where::everything(), + null, + null, + null, ); } @@ -125,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(); @@ -216,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] @@ -265,11 +255,35 @@ public function lazy(): bool return $this->lazy; } - private function buildColumns(Driver $driver): string + /** + * @internal + * + * @return Sequence + */ + public function names(): Sequence { - $columns = $this->columns->map( - static fn($column) => $column->sql($driver), + 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) { + $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();