From 1c1d40e493aaf9491277316e12e8770fc0811cc7 Mon Sep 17 00:00:00 2001 From: Lars Grevelink Date: Mon, 8 Mar 2021 19:57:17 +0100 Subject: [PATCH] - Merge wrap differences into base grammar - Move JSON related functions from query grammar to base grammar - Add JSON wrap functions to schema grammar - Add JSON support for MySQL and SQLite for virtualAs and storedAs columns --- src/Illuminate/Database/Grammar.php | 33 +++++++ .../Database/Query/Grammars/Grammar.php | 55 ------------ .../Database/Schema/Grammars/Grammar.php | 31 +++++++ .../Database/Schema/Grammars/MySqlGrammar.php | 29 +++++- .../Schema/Grammars/SQLiteGrammar.php | 29 +++++- .../DatabaseMySqlSchemaGrammarTest.php | 88 +++++++++++++++++++ .../DatabaseSQLiteSchemaGrammarTest.php | 66 ++++++++++++++ 7 files changed, 268 insertions(+), 63 deletions(-) diff --git a/src/Illuminate/Database/Grammar.php b/src/Illuminate/Database/Grammar.php index cc1e0b946940..ccea0916be2e 100755 --- a/src/Illuminate/Database/Grammar.php +++ b/src/Illuminate/Database/Grammar.php @@ -3,7 +3,9 @@ namespace Illuminate\Database; use Illuminate\Database\Query\Expression; +use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; +use RuntimeException; abstract class Grammar { @@ -62,6 +64,13 @@ public function wrap($value, $prefixAlias = false) return $this->wrapAliasedValue($value, $prefixAlias); } + // If the given value is a JSON selector we will wrap it differently than a + // traditional value. We will need to split this path and wrap each part + // wrapped, etc. Otherwise, we will simply wrap the value as a string. + if ($this->isJsonSelector($value)) { + return $this->wrapJsonSelector($value); + } + return $this->wrapSegments(explode('.', $value)); } @@ -116,6 +125,30 @@ protected function wrapValue($value) return $value; } + /** + * Wrap the given JSON selector. + * + * @param string $value + * @return string + * + * @throws \RuntimeException + */ + protected function wrapJsonSelector($value) + { + throw new RuntimeException('This database engine does not support JSON operations.'); + } + + /** + * Determine if the given string is a JSON selector. + * + * @param string $value + * @return bool + */ + protected function isJsonSelector($value) + { + return Str::contains($value, '->'); + } + /** * Convert an array of column names into a delimited string. * diff --git a/src/Illuminate/Database/Query/Grammars/Grammar.php b/src/Illuminate/Database/Query/Grammars/Grammar.php index b7305e8ea382..1e5c1b0b611d 100755 --- a/src/Illuminate/Database/Query/Grammars/Grammar.php +++ b/src/Illuminate/Database/Query/Grammars/Grammar.php @@ -6,7 +6,6 @@ use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\JoinClause; use Illuminate\Support\Arr; -use Illuminate\Support\Str; use RuntimeException; class Grammar extends BaseGrammar @@ -1144,49 +1143,6 @@ public function compileSavepointRollBack($name) return 'ROLLBACK TO SAVEPOINT '.$name; } - /** - * Wrap a value in keyword identifiers. - * - * @param \Illuminate\Database\Query\Expression|string $value - * @param bool $prefixAlias - * @return string - */ - public function wrap($value, $prefixAlias = false) - { - if ($this->isExpression($value)) { - return $this->getValue($value); - } - - // If the value being wrapped has a column alias we will need to separate out - // the pieces so we can wrap each of the segments of the expression on its - // own, and then join these both back together using the "as" connector. - if (stripos($value, ' as ') !== false) { - return $this->wrapAliasedValue($value, $prefixAlias); - } - - // If the given value is a JSON selector we will wrap it differently than a - // traditional value. We will need to split this path and wrap each part - // wrapped, etc. Otherwise, we will simply wrap the value as a string. - if ($this->isJsonSelector($value)) { - return $this->wrapJsonSelector($value); - } - - return $this->wrapSegments(explode('.', $value)); - } - - /** - * Wrap the given JSON selector. - * - * @param string $value - * @return string - * - * @throws \RuntimeException - */ - protected function wrapJsonSelector($value) - { - throw new RuntimeException('This database engine does not support JSON operations.'); - } - /** * Wrap the given JSON selector for boolean values. * @@ -1240,17 +1196,6 @@ protected function wrapJsonPath($value, $delimiter = '->') return '\'$."'.str_replace($delimiter, '"."', $value).'"\''; } - /** - * Determine if the given string is a JSON selector. - * - * @param string $value - * @return bool - */ - protected function isJsonSelector($value) - { - return Str::contains($value, '->'); - } - /** * Concatenate an array of segments, removing empties. * diff --git a/src/Illuminate/Database/Schema/Grammars/Grammar.php b/src/Illuminate/Database/Schema/Grammars/Grammar.php index 18071b2fbb12..3d0111da726b 100755 --- a/src/Illuminate/Database/Schema/Grammars/Grammar.php +++ b/src/Illuminate/Database/Schema/Grammars/Grammar.php @@ -241,6 +241,37 @@ public function wrapTable($table) ); } + /** + * Split the given JSON selector into the field and the optional path and wrap them separately. + * + * @param string $column + * @return array + */ + protected function wrapJsonFieldAndPath($column) + { + $parts = explode('->', $column, 2); + + $field = $this->wrap($parts[0]); + + $path = count($parts) > 1 ? ', '.$this->wrapJsonPath($parts[1], '->') : ''; + + return [$field, $path]; + } + + /** + * Wrap the given JSON path. + * + * @param string $value + * @param string $delimiter + * @return string + */ + protected function wrapJsonPath($value, $delimiter = '->') + { + $value = preg_replace("/([\\\\]+)?\\'/", "''", $value); + + return '\'$."'.str_replace($delimiter, '"."', $value).'"\''; + } + /** * Wrap a value in keyword identifiers. * diff --git a/src/Illuminate/Database/Schema/Grammars/MySqlGrammar.php b/src/Illuminate/Database/Schema/Grammars/MySqlGrammar.php index c1a2c5586b54..09f363064150 100755 --- a/src/Illuminate/Database/Schema/Grammars/MySqlGrammar.php +++ b/src/Illuminate/Database/Schema/Grammars/MySqlGrammar.php @@ -920,8 +920,12 @@ protected function typeComputed(Fluent $column) */ protected function modifyVirtualAs(Blueprint $blueprint, Fluent $column) { - if (! is_null($column->virtualAs)) { - return " as ({$column->virtualAs})"; + if (! is_null($virtualAs = $column->virtualAs)) { + if ($this->isJsonSelector($virtualAs)) { + $virtualAs = $this->wrapJsonSelector($virtualAs); + } + + return " as ({$virtualAs})"; } } @@ -934,8 +938,12 @@ protected function modifyVirtualAs(Blueprint $blueprint, Fluent $column) */ protected function modifyStoredAs(Blueprint $blueprint, Fluent $column) { - if (! is_null($column->storedAs)) { - return " as ({$column->storedAs}) stored"; + if (! is_null($storedAs = $column->storedAs)) { + if ($this->isJsonSelector($storedAs)) { + $storedAs = $this->wrapJsonSelector($storedAs); + } + + return " as ({$storedAs}) stored"; } } @@ -1097,4 +1105,17 @@ protected function wrapValue($value) return $value; } + + /** + * Wrap the given JSON selector. + * + * @param string $value + * @return string + */ + protected function wrapJsonSelector($value) + { + [$field, $path] = $this->wrapJsonFieldAndPath($value); + + return 'json_unquote(json_extract('.$field.$path.'))'; + } } diff --git a/src/Illuminate/Database/Schema/Grammars/SQLiteGrammar.php b/src/Illuminate/Database/Schema/Grammars/SQLiteGrammar.php index 556d749e23b2..d0dc3dd45efa 100755 --- a/src/Illuminate/Database/Schema/Grammars/SQLiteGrammar.php +++ b/src/Illuminate/Database/Schema/Grammars/SQLiteGrammar.php @@ -846,8 +846,12 @@ protected function typeComputed(Fluent $column) */ protected function modifyVirtualAs(Blueprint $blueprint, Fluent $column) { - if (! is_null($column->virtualAs)) { - return " as ({$column->virtualAs})"; + if (! is_null($virtualAs = $column->virtualAs)) { + if ($this->isJsonSelector($virtualAs)) { + $virtualAs = $this->wrapJsonSelector($virtualAs); + } + + return " as ({$virtualAs})"; } } @@ -860,8 +864,12 @@ protected function modifyVirtualAs(Blueprint $blueprint, Fluent $column) */ protected function modifyStoredAs(Blueprint $blueprint, Fluent $column) { - if (! is_null($column->storedAs)) { - return " as ({$column->storedAs}) stored"; + if (! is_null($storedAs = $column->storedAs)) { + if ($this->isJsonSelector($storedAs)) { + $storedAs = $this->wrapJsonSelector($storedAs); + } + + return " as ({$storedAs}) stored"; } } @@ -910,4 +918,17 @@ protected function modifyIncrement(Blueprint $blueprint, Fluent $column) return ' primary key autoincrement'; } } + + /** + * Wrap the given JSON selector. + * + * @param string $value + * @return string + */ + protected function wrapJsonSelector($value) + { + [$field, $path] = $this->wrapJsonFieldAndPath($value); + + return 'json_extract('.$field.$path.')'; + } } diff --git a/tests/Database/DatabaseMySqlSchemaGrammarTest.php b/tests/Database/DatabaseMySqlSchemaGrammarTest.php index 1ea53f991d16..4b1188faf415 100755 --- a/tests/Database/DatabaseMySqlSchemaGrammarTest.php +++ b/tests/Database/DatabaseMySqlSchemaGrammarTest.php @@ -1197,6 +1197,94 @@ public function testCreateDatabase() ); } + public function testCreateTableWithVirtualAsColumn() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8'); + $conn->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8_unicode_ci'); + $conn->shouldReceive('getConfig')->once()->with('engine')->andReturn(null); + + $blueprint = new Blueprint('users'); + $blueprint->create(); + $blueprint->string('my_column'); + $blueprint->string('my_other_column')->virtualAs('my_column'); + + $statements = $blueprint->toSql($conn, $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_column` varchar(255) not null, `my_other_column` varchar(255) as (my_column)) default character set utf8 collate 'utf8_unicode_ci'", $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->virtualAs('my_json_column->some_attribute'); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $statements = $blueprint->toSql($conn, $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_json_column` varchar(255) not null, `my_other_column` varchar(255) as (json_unquote(json_extract(`my_json_column`, '$.\"some_attribute\"'))))", $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->virtualAs('my_json_column->some_attribute->nested'); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $statements = $blueprint->toSql($conn, $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_json_column` varchar(255) not null, `my_other_column` varchar(255) as (json_unquote(json_extract(`my_json_column`, '$.\"some_attribute\".\"nested\"'))))", $statements[0]); + } + + public function testCreateTableWithStoredAsColumn() + { + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8'); + $conn->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8_unicode_ci'); + $conn->shouldReceive('getConfig')->once()->with('engine')->andReturn(null); + + $blueprint = new Blueprint('users'); + $blueprint->create(); + $blueprint->string('my_column'); + $blueprint->string('my_other_column')->storedAs('my_column'); + + $statements = $blueprint->toSql($conn, $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_column` varchar(255) not null, `my_other_column` varchar(255) as (my_column) stored) default character set utf8 collate 'utf8_unicode_ci'", $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->storedAs('my_json_column->some_attribute'); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $statements = $blueprint->toSql($conn, $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_json_column` varchar(255) not null, `my_other_column` varchar(255) as (json_unquote(json_extract(`my_json_column`, '$.\"some_attribute\"'))) stored)", $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->storedAs('my_json_column->some_attribute->nested'); + + $conn = $this->getConnection(); + $conn->shouldReceive('getConfig')->andReturn(null); + + $statements = $blueprint->toSql($conn, $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame("create table `users` (`my_json_column` varchar(255) not null, `my_other_column` varchar(255) as (json_unquote(json_extract(`my_json_column`, '$.\"some_attribute\".\"nested\"'))) stored)", $statements[0]); + } + public function testDropDatabaseIfExists() { $statement = $this->getGrammar()->compileDropDatabaseIfExists('my_database_a'); diff --git a/tests/Database/DatabaseSQLiteSchemaGrammarTest.php b/tests/Database/DatabaseSQLiteSchemaGrammarTest.php index e77e21578cbe..4b143d9f8891 100755 --- a/tests/Database/DatabaseSQLiteSchemaGrammarTest.php +++ b/tests/Database/DatabaseSQLiteSchemaGrammarTest.php @@ -899,6 +899,72 @@ public function testGrammarsAreMacroable() $this->assertTrue($c); } + public function testCreateTableWithVirtualAsColumn() + { + $blueprint = new Blueprint('users'); + $blueprint->create(); + $blueprint->string('my_column'); + $blueprint->string('my_other_column')->virtualAs('my_column'); + + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create table "users" ("my_column" varchar not null, "my_other_column" varchar as (my_column))', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->virtualAs('my_json_column->some_attribute'); + + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create table "users" ("my_json_column" varchar not null, "my_other_column" varchar as (json_extract("my_json_column", \'$."some_attribute"\')))', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->virtualAs('my_json_column->some_attribute->nested'); + + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create table "users" ("my_json_column" varchar not null, "my_other_column" varchar as (json_extract("my_json_column", \'$."some_attribute"."nested"\')))', $statements[0]); + } + + public function testCreateTableWithStoredAsColumn() + { + $blueprint = new Blueprint('users'); + $blueprint->create(); + $blueprint->string('my_column'); + $blueprint->string('my_other_column')->storedAs('my_column'); + + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create table "users" ("my_column" varchar not null, "my_other_column" varchar as (my_column) stored)', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->storedAs('my_json_column->some_attribute'); + + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create table "users" ("my_json_column" varchar not null, "my_other_column" varchar as (json_extract("my_json_column", \'$."some_attribute"\')) stored)', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->create(); + $blueprint->string('my_json_column'); + $blueprint->string('my_other_column')->storedAs('my_json_column->some_attribute->nested'); + + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create table "users" ("my_json_column" varchar not null, "my_other_column" varchar as (json_extract("my_json_column", \'$."some_attribute"."nested"\')) stored)', $statements[0]); + } + protected function getConnection() { return m::mock(Connection::class);