diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..27b765f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/tests export-ignore +/.github export-ignore diff --git a/.github/workflows/close-pull-request.yml b/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..07d8be8 --- /dev/null +++ b/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [ opened ] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "Hi, this is a READ-ONLY repository, please submit your PR on the https://github.com/hyperf/hyperf repository.

This Pull Request will close automatically.

Thanks! " diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4ebb5d0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,25 @@ +on: + push: + # Sequence of patterns matched against refs/tags + tags: + - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + +name: Release + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c35d3f5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Hyperf + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8d35a13 --- /dev/null +++ b/composer.json @@ -0,0 +1,48 @@ +{ + "name": "hyperf/database-sqlite", + "type": "library", + "description": "The sqlite driver for hyperf/database.", + "license": "MIT", + "keywords": [ + "php", + "swoole", + "hyperf", + "database", + "sqlite" + ], + "homepage": "https://hyperf.io", + "support": { + "docs": "https://hyperf.wiki", + "issues": "https://github.com/hyperf/hyperf/issues", + "pull-request": "https://github.com/hyperf/hyperf/pulls", + "source": "https://github.com/hyperf/hyperf" + }, + "require": { + "php": ">=8.1", + "hyperf/collection": "~3.1.0", + "hyperf/database": "~3.1.0", + "hyperf/support": "~3.1.0", + "hyperf/stringable": "~3.1.0" + }, + "autoload": { + "psr-4": { + "Hyperf\\Database\\SQLite\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "HyperfTest\\Database\\SQLite\\": "tests/" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + }, + "hyperf": { + "config": "Hyperf\\Database\\SQLite\\ConfigProvider" + } + } +} diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php new file mode 100644 index 0000000..8adb77b --- /dev/null +++ b/src/ConfigProvider.php @@ -0,0 +1,30 @@ + [ + 'db.connector.sqlite' => SQLiteConnector::class, + ], + 'listeners' => [ + RegisterConnectionListener::class, + ], + ]; + } +} diff --git a/src/Connectors/SQLiteConnector.php b/src/Connectors/SQLiteConnector.php new file mode 100644 index 0000000..ee41335 --- /dev/null +++ b/src/Connectors/SQLiteConnector.php @@ -0,0 +1,50 @@ +getOptions($config); + + // SQLite supports "in-memory" databases that only last as long as the owning + // connection does. These are useful for tests or for short lifetime store + // querying. In-memory databases may only have a single open connection. + if ($config['database'] === ':memory:') { + return $this->createConnection('sqlite::memory:', $config, $options); + } + + $path = realpath($config['database']); + + // Here we'll verify that the SQLite database exists before going any further + // as the developer probably wants to know if the database exists and this + // SQLite driver will not throw any exception if it does not by default. + if ($path === false) { + throw new InvalidArgumentException("Database ({$config['database']}) does not exist."); + } + + return $this->createConnection("sqlite:{$path}", $config, $options); + } +} diff --git a/src/Listener/RegisterConnectionListener.php b/src/Listener/RegisterConnectionListener.php new file mode 100644 index 0000000..9cbeba5 --- /dev/null +++ b/src/Listener/RegisterConnectionListener.php @@ -0,0 +1,65 @@ +createPersistentPdoResolver($connection, $config); + } + + return new SQLiteConnection($connection, $database, $prefix, $config); + }); + } + + protected function createPersistentPdoResolver($connection, $config) + { + return function () use ($config, $connection) { + /** @var \Hyperf\Contract\ContainerInterface $container */ + $container = ApplicationContext::getContainer(); + $key = "sqlite.presistent.pdo.{$config['name']}"; + + if (! $container->has($key)) { + $container->set($key, call_user_func($connection)); + } + + return $container->get($key); + }; + } +} diff --git a/src/Query/Grammars/SQLiteGrammar.php b/src/Query/Grammars/SQLiteGrammar.php new file mode 100644 index 0000000..3b643f6 --- /dev/null +++ b/src/Query/Grammars/SQLiteGrammar.php @@ -0,0 +1,301 @@ +', '<=', '>=', '<>', '!=', + 'like', 'not like', 'ilike', + '&', '|', '<<', '>>', + ]; + + /** + * Compile an update statement into SQL. + * + * @param array $values + */ + public function compileUpdate(Builder $query, $values): string + { + if ($query->joins || $query->limit) { + return $this->compileUpdateWithJoinsOrLimit($query, $values); + } + + return parent::compileUpdate($query, $values); + } + + /** + * Compile an insert ignore statement into SQL. + */ + public function compileInsertOrIgnore(Builder $query, array $values): string + { + return Str::replaceFirst('insert', 'insert or ignore', $this->compileInsert($query, $values)); + } + + /** + * Compile an "upsert" statement into SQL. + */ + public function compileUpsert(Builder $query, array $values, array $uniqueBy, array $update): string + { + $sql = $this->compileInsert($query, $values); + + $sql .= ' on conflict (' . $this->columnize($uniqueBy) . ') do update set '; + + $columns = collect($update)->map(function ($value, $key) { + return is_numeric($key) + ? $this->wrap($value) . ' = ' . $this->wrapValue('excluded') . '.' . $this->wrap($value) + : $this->wrap($key) . ' = ' . $this->parameter($value); + })->implode(', '); + + return $sql . $columns; + } + + /** + * Prepare the bindings for an update statement. + */ + public function prepareBindingsForUpdate(array $bindings, array $values): array + { + $groups = $this->groupJsonColumnsForUpdate($values); + + $values = collect($values)->reject(function ($value, $key) { + return $this->isJsonSelector($key); + })->merge($groups)->map(function ($value) { + return is_array($value) ? json_encode($value) : $value; + })->all(); + + $cleanBindings = Arr::except($bindings, 'select'); + + return array_values( + array_merge($values, Arr::flatten($cleanBindings)) + ); + } + + /** + * Compile a delete statement into SQL. + */ + public function compileDelete(Builder $query): string + { + if ($query->joins || $query->limit) { + return $this->compileDeleteWithJoinsOrLimit($query); + } + + return parent::compileDelete($query); + } + + /** + * Compile a truncate table statement into SQL. + */ + public function compileTruncate(Builder $query): array + { + return [ + 'delete from sqlite_sequence where name = ?' => [$query->from], + 'delete from ' . $this->wrapTable($query->from) => [], + ]; + } + + /** + * Compile the lock into SQL. + * + * @param bool|string $value + */ + protected function compileLock(Builder $query, $value): string + { + return ''; + } + + /** + * Wrap a union subquery in parentheses. + * + * @param string $sql + */ + protected function wrapUnion($sql): string + { + return 'select * from (' . $sql . ')'; + } + + /** + * Compile a "where date" clause. + * + * @param array $where + */ + protected function whereDate(Builder $query, $where): string + { + return $this->dateBasedWhere('%Y-%m-%d', $query, $where); + } + + /** + * Compile a "where day" clause. + * + * @param array $where + */ + protected function whereDay(Builder $query, $where): string + { + return $this->dateBasedWhere('%d', $query, $where); + } + + /** + * Compile a "where month" clause. + * + * @param array $where + */ + protected function whereMonth(Builder $query, $where): string + { + return $this->dateBasedWhere('%m', $query, $where); + } + + /** + * Compile a "where year" clause. + * + * @param array $where + */ + protected function whereYear(Builder $query, $where): string + { + return $this->dateBasedWhere('%Y', $query, $where); + } + + /** + * Compile a "where time" clause. + * + * @param array $where + */ + protected function whereTime(Builder $query, $where): string + { + return $this->dateBasedWhere('%H:%M:%S', $query, $where); + } + + /** + * Compile a date based where clause. + * + * @param string $type + * @param array $where + */ + protected function dateBasedWhere($type, Builder $query, $where): string + { + $value = $this->parameter($where['value']); + + return "strftime('{$type}', {$this->wrap($where['column'])}) {$where['operator']} cast({$value} as text)"; + } + + /** + * Compile a "JSON length" statement into SQL. + * + * @param string $column + * @param string $operator + * @param string $value + */ + protected function compileJsonLength($column, $operator, $value): string + { + [$field, $path] = $this->wrapJsonFieldAndPath($column); + + return 'json_array_length(' . $field . $path . ') ' . $operator . ' ' . $value; + } + + /** + * Compile the columns for an update statement. + */ + protected function compileUpdateColumns(Builder $query, array $values): string + { + $jsonGroups = $this->groupJsonColumnsForUpdate($values); + + return collect($values)->reject(function ($value, $key) { + return $this->isJsonSelector($key); + })->merge($jsonGroups)->map(function ($value, $key) use ($jsonGroups) { + $column = last(explode('.', $key)); + + $value = isset($jsonGroups[$key]) ? $this->compileJsonPatch($column, $value) : $this->parameter($value); + + return $this->wrap($column) . ' = ' . $value; + })->implode(', '); + } + + /** + * Group the nested JSON columns. + */ + protected function groupJsonColumnsForUpdate(array $values): array + { + $groups = []; + + foreach ($values as $key => $value) { + if ($this->isJsonSelector($key)) { + Arr::set($groups, str_replace('->', '.', Str::after($key, '.')), $value); + } + } + + return $groups; + } + + /** + * Compile a "JSON" patch statement into SQL. + * + * @param string $column + * @param mixed $value + */ + protected function compileJsonPatch($column, $value): string + { + return "json_patch(ifnull({$this->wrap($column)}, json('{}')), json({$this->parameter($value)}))"; + } + + /** + * Compile an update statement with joins or limit into SQL. + */ + protected function compileUpdateWithJoinsOrLimit(Builder $query, array $values): string + { + $table = $this->wrapTable($query->from); + + $columns = $this->compileUpdateColumns($query, $values); + + $alias = last(preg_split('/\s+as\s+/i', $query->from)); + + $selectSql = $this->compileSelect($query->select($alias . '.rowid')); + + return "update {$table} set {$columns} where {$this->wrap('rowid')} in ({$selectSql})"; + } + + /** + * Compile a delete statement with joins or limit into SQL. + */ + protected function compileDeleteWithJoinsOrLimit(Builder $query): string + { + $table = $this->wrapTable($query->from); + + $alias = last(preg_split('/\s+as\s+/i', $query->from)); + + $selectSql = $this->compileSelect($query->select($alias . '.rowid')); + + return "delete from {$table} where {$this->wrap('rowid')} in ({$selectSql})"; + } + + /** + * Wrap the given JSON selector. + * + * @param string $value + */ + protected function wrapJsonSelector($value): string + { + [$field, $path] = $this->wrapJsonFieldAndPath($value); + + return 'json_extract(' . $field . $path . ')'; + } +} diff --git a/src/Query/Processors/SQLiteProcessor.php b/src/Query/Processors/SQLiteProcessor.php new file mode 100644 index 0000000..0e72d03 --- /dev/null +++ b/src/Query/Processors/SQLiteProcessor.php @@ -0,0 +1,97 @@ +name; + }, $results); + } + + /** + * Process the results of a columns query. + */ + public function processColumns(array $results): array + { + return array_map(function ($result) { + return new Column( + 'main', + $result['table_name'], + $result['column_name'], + $result['cid'] + 1, + $result['default'], + (bool) $result['nullable'], + strtok(strtolower($result['type']), '(') ?: '', + '' + ); + }, $results); + } + + /** + * Process the results of an indexes query. + */ + public function processIndexes(array $results): array + { + $primaryCount = 0; + + $indexes = array_map(function ($result) use (&$primaryCount) { + $result = (object) $result; + + if ($isPrimary = (bool) $result->primary) { + ++$primaryCount; + } + + return [ + 'name' => strtolower($result->name), + 'columns' => explode(',', $result->columns), + 'type' => null, + 'unique' => (bool) $result->unique, + 'primary' => $isPrimary, + ]; + }, $results); + + if ($primaryCount > 1) { + $indexes = array_filter($indexes, fn ($index) => $index['name'] !== 'primary'); + } + + return $indexes; + } + + /** + * Process the results of a foreign keys query. + */ + public function processForeignKeys(array $results): array + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => null, + 'columns' => explode(',', $result->columns), + 'foreign_schema' => null, + 'foreign_table' => $result->foreign_table, + 'foreign_columns' => explode(',', $result->foreign_columns), + 'on_update' => strtolower($result->on_update), + 'on_delete' => strtolower($result->on_delete), + ]; + }, $results); + } +} diff --git a/src/SQLiteConnection.php b/src/SQLiteConnection.php new file mode 100644 index 0000000..261e9f3 --- /dev/null +++ b/src/SQLiteConnection.php @@ -0,0 +1,116 @@ +getForeignKeyConstraintsConfigurationValue(); + + if ($enableForeignKeyConstraints === null) { + return; + } + + $enableForeignKeyConstraints + ? $this->getSchemaBuilder()->enableForeignKeyConstraints() + : $this->getSchemaBuilder()->disableForeignKeyConstraints(); + } + + /** + * Get a schema builder instance for the connection. + * + * @return \Hyperf\Database\SQLite\Schema\SQLiteBuilder + */ + public function getSchemaBuilder(): SchemaBuilder + { + if (is_null($this->schemaGrammar)) { + $this->useDefaultSchemaGrammar(); + } + + return new SQLiteBuilder($this); + } + + /** + * Get the default query grammar instance. + * + * @return \Hyperf\Database\SQLite\Query\Grammars\SQLiteGrammar + */ + protected function getDefaultQueryGrammar(): HyperfQueryGrammar + { + /* @phpstan-ignore-next-line */ + return $this->withTablePrefix(new QueryGrammar()); + } + + /** + * Get the default schema grammar instance. + * + * @return \Hyperf\Database\SQLite\Schema\Grammars\SQLiteGrammar + */ + protected function getDefaultSchemaGrammar(): HyperfSchemaGrammar + { + /* @phpstan-ignore-next-line */ + return $this->withTablePrefix(new SchemaGrammar()); + } + + /** + * Get the default post processor instance. + * + * @return \Hyperf\Database\SQLite\Query\Processors\SQLiteProcessor + */ + protected function getDefaultPostProcessor(): Processor + { + return new SQLiteProcessor(); + } + + /** + * Get the Doctrine DBAL driver. + * + * @return \Doctrine\DBAL\Driver\PDO\SQLite\Driver + */ + protected function getDoctrineDriver() + { + return new DoctrineDriver(); + } + + /** + * Get the database connection foreign key constraints configuration option. + * + * @return null|bool + */ + protected function getForeignKeyConstraintsConfigurationValue() + { + return $this->getConfig('foreign_key_constraints'); + } +} diff --git a/src/Schema/Grammars/SQLiteGrammar.php b/src/Schema/Grammars/SQLiteGrammar.php new file mode 100644 index 0000000..88a7e9e --- /dev/null +++ b/src/Schema/Grammars/SQLiteGrammar.php @@ -0,0 +1,1012 @@ +wrap(str_replace('.', '__', $table)) . ')'; + } + + /** + * Compile the query to determine the columns. + */ + public function compileColumns(string $table): string + { + return sprintf( + 'select %s as "table_name", name as "column_name", type, not "notnull" as "nullable", dflt_value as "default", pk as "primary", cid ' + . 'from pragma_table_info(%s) order by cid asc', + $tableName = $this->wrap(str_replace('.', '__', $table)), + $tableName + ); + } + + /** + * Compile the query to determine the indexes. + */ + public function compileIndexes(string $table): string + { + return sprintf( + 'select "primary" as name, group_concat(col) as columns, 1 as "unique", 1 as "primary" ' + . 'from (select name as col from pragma_table_info(%s) where pk > 0 order by pk, cid) group by name ' + . 'union select name, group_concat(col) as columns, "unique", origin = "pk" as "primary" ' + . 'from (select il.*, ii.name as col from pragma_index_list(%s) il, pragma_index_info(il.name) ii order by il.seq, ii.seqno) ' + . 'group by name, "unique", "primary"', + $table = $this->wrap(str_replace('.', '__', $table)), + $table + ); + } + + /** + * Compile the query to determine the foreign keys. + */ + public function compileForeignKeys(string $table): string + { + return sprintf( + 'select group_concat("from") as columns, "table" as foreign_table, ' + . 'group_concat("to") as foreign_columns, on_update, on_delete ' + . 'from (select * from pragma_foreign_key_list(%s) order by id desc, seq) ' + . 'group by id, "table", on_update, on_delete', + $this->wrap(str_replace('.', '__', $table)) + ); + } + + /** + * Compile a create table command. + */ + public function compileCreate(Blueprint $blueprint, Fluent $command): string + { + return sprintf( + '%s table %s (%s%s%s)', + $blueprint->temporary ? 'create temporary' : 'create', + $this->wrapTable($blueprint), + implode(', ', $this->getColumns($blueprint)), + (string) $this->addForeignKeys($blueprint), + (string) $this->addPrimaryKeys($blueprint) + ); + } + + /** + * Compile alter table commands for adding columns. + */ + public function compileAdd(Blueprint $blueprint, Fluent $command): array + { + $columns = $this->prefixArray('add column', $this->getColumns($blueprint)); + + return collect($columns)->reject(function ($column) { + return preg_match('/as \(.*\) stored/', $column) > 0; + })->map(function ($column) use ($blueprint) { + return 'alter table ' . $this->wrapTable($blueprint) . ' ' . $column; + })->all(); + } + + /** + * Compile a unique key command. + */ + public function compileUnique(Blueprint $blueprint, Fluent $command): string + { + return sprintf( + 'create unique index %s on %s (%s)', + $this->wrap($command->index), + $this->wrapTable($blueprint), + $this->columnize($command->columns) + ); + } + + /** + * Compile a plain index key command. + */ + public function compileIndex(Blueprint $blueprint, Fluent $command): string + { + return sprintf( + 'create index %s on %s (%s)', + $this->wrap($command->index), + $this->wrapTable($blueprint), + $this->columnize($command->columns) + ); + } + + /** + * Compile a spatial index key command. + * + * @throws RuntimeException + */ + public function compileSpatialIndex(Blueprint $blueprint, Fluent $command): void + { + throw new RuntimeException('The database driver in use does not support spatial indexes.'); + } + + /** + * Compile a foreign key command. + */ + public function compileForeign(Blueprint $blueprint, Fluent $command): string + { + // Handled on table creation... + return ''; + } + + /** + * Compile a drop table command. + */ + public function compileDrop(Blueprint $blueprint, Fluent $command): string + { + return 'drop table ' . $this->wrapTable($blueprint); + } + + /** + * Compile a drop table (if exists) command. + */ + public function compileDropIfExists(Blueprint $blueprint, Fluent $command): string + { + return 'drop table if exists ' . $this->wrapTable($blueprint); + } + + /** + * Compile the SQL needed to drop all tables. + */ + public function compileDropAllTables(): string + { + return "delete from sqlite_master where type in ('table', 'index', 'trigger')"; + } + + /** + * Compile the SQL needed to drop all views. + */ + public function compileDropAllViews(): string + { + return "delete from sqlite_master where type in ('view')"; + } + + /** + * Compile the SQL needed to rebuild the database. + */ + public function compileRebuild(): string + { + return 'vacuum'; + } + + /** + * Compile a drop column command. + */ + public function compileDropColumn(Blueprint $blueprint, Fluent $command): array + { + $table = $this->wrapTable($blueprint); + + $columns = $this->prefixArray('drop column', $this->wrapArray($command->columns)); + + return collect($columns) + ->map( + fn ($column) => 'alter table ' . $table . ' ' . $column + )->all(); + } + + /** + * Compile a drop unique key command. + */ + public function compileDropUnique(Blueprint $blueprint, Fluent $command): string + { + $index = $this->wrap($command->index); + + return "drop index {$index}"; + } + + /** + * Compile a drop index command. + */ + public function compileDropIndex(Blueprint $blueprint, Fluent $command): string + { + $index = $this->wrap($command->index); + + return "drop index {$index}"; + } + + /** + * Compile a drop spatial index command. + * + * @throws RuntimeException + */ + public function compileDropSpatialIndex(Blueprint $blueprint, Fluent $command): void + { + throw new RuntimeException('The database driver in use does not support spatial indexes.'); + } + + /** + * Compile a rename table command. + */ + public function compileRename(Blueprint $blueprint, Fluent $command): string + { + $from = $this->wrapTable($blueprint); + + return "alter table {$from} rename to " . $this->wrapTable($command->to); + } + + /** + * Compile a rename index command. + * + * @throws RuntimeException + */ + public function compileRenameIndex(Blueprint $blueprint, Fluent $command, Connection $connection): array + { + $indexes = $this->getIndexColumns($connection, $blueprint->getTable()); + if (! $index = Arr::get($indexes, $command->from)) { + throw new RuntimeException("Index [{$command->from}] does not exist."); + } + + $compileMethod = $index['unique'] ? 'compileUnique' : 'compileIndex'; + + return [ + $this->compileDropIndex($blueprint, new Fluent(['index' => $command->from])), + $this->{$compileMethod}( + $blueprint, + new Fluent(['index' => $command->to, 'columns' => $index['columns']]) + )]; + } + + /** + * Compile the command to enable foreign key constraints. + */ + public function compileEnableForeignKeyConstraints(): string + { + return 'PRAGMA foreign_keys = ON;'; + } + + /** + * Compile the command to disable foreign key constraints. + */ + public function compileDisableForeignKeyConstraints(): string + { + return 'PRAGMA foreign_keys = OFF;'; + } + + /** + * Compile the SQL needed to enable a writable schema. + */ + public function compileEnableWriteableSchema(): string + { + return 'PRAGMA writable_schema = 1;'; + } + + /** + * Compile the SQL needed to disable a writable schema. + */ + public function compileDisableWriteableSchema(): string + { + return 'PRAGMA writable_schema = 0;'; + } + + /** + * Create the column definition for a spatial Geometry type. + */ + public function typeGeometry(Fluent $column): string + { + return 'geometry'; + } + + /** + * Create the column definition for a spatial Point type. + */ + public function typePoint(Fluent $column): string + { + return 'point'; + } + + /** + * Create the column definition for a spatial LineString type. + */ + public function typeLineString(Fluent $column): string + { + return 'linestring'; + } + + /** + * Create the column definition for a spatial Polygon type. + */ + public function typePolygon(Fluent $column): string + { + return 'polygon'; + } + + /** + * Create the column definition for a spatial GeometryCollection type. + */ + public function typeGeometryCollection(Fluent $column): string + { + return 'geometrycollection'; + } + + /** + * Create the column definition for a spatial MultiPoint type. + */ + public function typeMultiPoint(Fluent $column): string + { + return 'multipoint'; + } + + /** + * Create the column definition for a spatial MultiLineString type. + */ + public function typeMultiLineString(Fluent $column): string + { + return 'multilinestring'; + } + + /** + * Create the column definition for a spatial MultiPolygon type. + */ + public function typeMultiPolygon(Fluent $column): string + { + return 'multipolygon'; + } + + protected function getIndexColumns(Connection $connection, string $tableName = null): array + { + $sql = <<<'SQL' + SELECT t.name AS table_name, + i.* + FROM sqlite_master t + JOIN pragma_index_list(t.name) i +SQL; + + $conditions = [ + "t.type = 'table'", + "t.name NOT IN ('geometry_columns', 'spatial_ref_sys', 'sqlite_sequence')", + ]; + + if ($tableName !== null) { + $tableName = str_replace('.', '__', $tableName); + $conditions[] = "t.name = '{$tableName}'"; + } + + $sql .= ' WHERE ' . implode(' AND ', $conditions) . ' ORDER BY t.name, i.seq'; + + return $this->getTableIndexesList( + $connection, + $connection->select($sql), + $tableName + ); + } + + /** + * @see http://ezcomponents.org/docs/api/trunk/DatabaseSchema/ezcDbSchemaPgsqlReader.html + */ + protected function getTableIndexesList(Connection $connection, array $tableIndexes, string $tableName = null): array + { + $indexBuffer = []; + + // fetch primary + $indexArray = $connection->select("SELECT * FROM PRAGMA_TABLE_INFO ('{$tableName}')"); + + usort( + $indexArray, + static function ($a, $b): int { + if ($a->pk === $b->pk) { + return $a->cid - $b->cid; + } + + return $a->pk - $b->pk; + }, + ); + + foreach ($indexArray as $indexColumnRow) { + if ($indexColumnRow->pk === 0 || $indexColumnRow->pk === '0') { + continue; + } + + $indexBuffer[] = [ + 'key_name' => 'primary', + 'primary' => true, + 'non_unique' => false, + 'column_name' => $indexColumnRow->name, + 'where' => null, + 'flags' => null, + 'length' => null, + ]; + } + + // fetch regular indexes + foreach ($tableIndexes as $tableIndex) { + // Ignore indexes with reserved names, e.g. autoindexes + if (str_starts_with($tableIndex->name, 'sqlite_')) { + continue; + } + + $keyName = $tableIndex->name; + $idx = []; + $idx['key_name'] = $keyName; + $idx['primary'] = false; + $idx['non_unique'] = ! $tableIndex->unique; + + $indexArray = $connection->select("SELECT * FROM PRAGMA_INDEX_INFO ('{$keyName}')"); + + foreach ($indexArray as $indexColumnRow) { + $idx['column_name'] = $indexColumnRow->name; + $indexBuffer[] = $idx; + } + } + + $result = []; + foreach ($indexBuffer as $tableIndex) { + $indexName = $keyName = $tableIndex['key_name']; + if ($tableIndex['primary']) { + $keyName = 'primary'; + } + + $keyName = strtolower($keyName); + + if (! isset($result[$keyName])) { + $options = [ + 'lengths' => [], + ]; + + if (isset($tableIndex['where'])) { + $options['where'] = $tableIndex['where']; + } + + $result[$keyName] = [ + 'name' => $indexName, + 'columns' => [], + 'unique' => ! $tableIndex['non_unique'], + 'primary' => $tableIndex['primary'], + 'flags' => $tableIndex['flags'] ?? [], + 'options' => $options, + ]; + } + + $result[$keyName]['columns'][] = $tableIndex['column_name']; + $result[$keyName]['options']['lengths'][] = $tableIndex['length'] ?? null; + } + + return $result; + } + + /** + * Get the foreign key syntax for a table creation statement. + */ + protected function addForeignKeys(Blueprint $blueprint): ?string + { + $foreigns = $this->getCommandsByName($blueprint, 'foreign'); + + return collect($foreigns)->reduce(function ($sql, $foreign) { + // Once we have all the foreign key commands for the table creation statement + // we'll loop through each of them and add them to the create table SQL we + // are building, since SQLite needs foreign keys on the tables creation. + $sql .= $this->getForeignKey($foreign); + + if (! is_null($foreign->onDelete)) { + $sql .= " on delete {$foreign->onDelete}"; + } + + // If this foreign key specifies the action to be taken on update we will add + // that to the statement here. We'll append it to this SQL and then return + // the SQL so we can keep adding any other foreign constraints onto this. + if (! is_null($foreign->onUpdate)) { + $sql .= " on update {$foreign->onUpdate}"; + } + + return $sql; + }, ''); + } + + /** + * Get the SQL for the foreign key. + * + * @param \Hyperf\Support\Fluent $foreign + */ + protected function getForeignKey($foreign): string + { + // We need to columnize the columns that the foreign key is being defined for + // so that it is a properly formatted list. Once we have done this, we can + // return the foreign key SQL declaration to the calling method for use. + return sprintf( + ', foreign key(%s) references %s(%s)', + $this->columnize($foreign->columns), + $this->wrapTable($foreign->on), + $this->columnize((array) $foreign->references) + ); + } + + /** + * Get the primary key syntax for a table creation statement. + */ + protected function addPrimaryKeys(Blueprint $blueprint): ?string + { + if (! is_null($primary = $this->getCommandByName($blueprint, 'primary'))) { + return ", primary key ({$this->columnize($primary->columns)})"; + } + + return null; + } + + /** + * Create the column definition for a char type. + */ + protected function typeChar(Fluent $column): string + { + return 'varchar'; + } + + /** + * Create the column definition for a string type. + */ + protected function typeString(Fluent $column): string + { + return 'varchar'; + } + + /** + * Create the column definition for a tiny text type. + */ + protected function typeTinyText(Fluent $column): string + { + return 'text'; + } + + /** + * Create the column definition for a text type. + */ + protected function typeText(Fluent $column): string + { + return 'text'; + } + + /** + * Create the column definition for a medium text type. + */ + protected function typeMediumText(Fluent $column): string + { + return 'text'; + } + + /** + * Create the column definition for a long text type. + */ + protected function typeLongText(Fluent $column): string + { + return 'text'; + } + + /** + * Create the column definition for an integer type. + */ + protected function typeInteger(Fluent $column): string + { + return 'integer'; + } + + /** + * Create the column definition for a big integer type. + */ + protected function typeBigInteger(Fluent $column): string + { + return 'integer'; + } + + /** + * Create the column definition for a medium integer type. + */ + protected function typeMediumInteger(Fluent $column): string + { + return 'integer'; + } + + /** + * Create the column definition for a tiny integer type. + */ + protected function typeTinyInteger(Fluent $column): string + { + return 'integer'; + } + + /** + * Create the column definition for a small integer type. + */ + protected function typeSmallInteger(Fluent $column): string + { + return 'integer'; + } + + /** + * Create the column definition for a float type. + */ + protected function typeFloat(Fluent $column): string + { + return 'float'; + } + + /** + * Create the column definition for a double type. + */ + protected function typeDouble(Fluent $column): string + { + return 'float'; + } + + /** + * Create the column definition for a decimal type. + */ + protected function typeDecimal(Fluent $column): string + { + return 'numeric'; + } + + /** + * Create the column definition for a boolean type. + */ + protected function typeBoolean(Fluent $column): string + { + return 'tinyint(1)'; + } + + /** + * Create the column definition for an enumeration type. + */ + protected function typeEnum(Fluent $column): string + { + return sprintf( + 'varchar check ("%s" in (%s))', + $column->name, + $this->quoteString($column->allowed) + ); + } + + /** + * Create the column definition for a json type. + */ + protected function typeJson(Fluent $column): string + { + return 'text'; + } + + /** + * Create the column definition for a jsonb type. + */ + protected function typeJsonb(Fluent $column): string + { + return 'text'; + } + + /** + * Create the column definition for a date type. + */ + protected function typeDate(Fluent $column): string + { + return 'date'; + } + + /** + * Create the column definition for a date-time type. + */ + protected function typeDateTime(Fluent $column): string + { + return $this->typeTimestamp($column); + } + + /** + * Create the column definition for a date-time (with time zone) type. + * + * Note: "SQLite does not have a storage class set aside for storing dates and/or times." + * + * @see https://www.sqlite.org/datatype3.html + */ + protected function typeDateTimeTz(Fluent $column): string + { + return $this->typeDateTime($column); + } + + /** + * Create the column definition for a time type. + */ + protected function typeTime(Fluent $column): string + { + return 'time'; + } + + /** + * Create the column definition for a time (with time zone) type. + */ + protected function typeTimeTz(Fluent $column): string + { + return $this->typeTime($column); + } + + /** + * Create the column definition for a timestamp type. + */ + protected function typeTimestamp(Fluent $column): string + { + return $column->useCurrent ? 'datetime default CURRENT_TIMESTAMP' : 'datetime'; + } + + /** + * Create the column definition for a timestamp (with time zone) type. + */ + protected function typeTimestampTz(Fluent $column): string + { + return $this->typeTimestamp($column); + } + + /** + * Create the column definition for a year type. + */ + protected function typeYear(Fluent $column): string + { + return $this->typeInteger($column); + } + + /** + * Create the column definition for a binary type. + */ + protected function typeBinary(Fluent $column): string + { + return 'blob'; + } + + /** + * Create the column definition for a uuid type. + */ + protected function typeUuid(Fluent $column): string + { + return 'varchar'; + } + + /** + * Create the column definition for an IP address type. + */ + protected function typeIpAddress(Fluent $column): string + { + return 'varchar'; + } + + /** + * Create the column definition for a MAC address type. + */ + protected function typeMacAddress(Fluent $column): string + { + return 'varchar'; + } + + /** + * Create the column definition for a generated, computed column type. + * + * @throws RuntimeException + */ + protected function typeComputed(Fluent $column): void + { + throw new RuntimeException('This database driver requires a type, see the virtualAs / storedAs modifiers.'); + } + + /** + * Get the SQL for a generated virtual column modifier. + */ + protected function modifyVirtualAs(Blueprint $blueprint, Fluent $column): ?string + { + if (! is_null($virtualAs = $column->virtualAsJson)) { + if ($this->isJsonSelector($virtualAs)) { + $virtualAs = $this->wrapJsonSelector($virtualAs); + } + + return " as ({$virtualAs})"; + } + + if (! is_null($column->virtualAs)) { + return " as ({$column->virtualAs})"; + } + + return null; + } + + /** + * Get the SQL for a generated stored column modifier. + */ + protected function modifyStoredAs(Blueprint $blueprint, Fluent $column): ?string + { + if (! is_null($storedAs = $column->storedAsJson)) { + if ($this->isJsonSelector($storedAs)) { + $storedAs = $this->wrapJsonSelector($storedAs); + } + + return " as ({$storedAs}) stored"; + } + + if (! is_null($column->storedAs)) { + return " as ({$column->storedAs}) stored"; + } + + return null; + } + + /** + * Determine if the given string is a JSON selector. + */ + protected function isJsonSelector(string $value): bool + { + return str_contains($value, '->'); + } + + /** + * Wrap the given JSON selector. + */ + protected function wrapJsonSelector(string $value): string + { + [$field, $path] = $this->wrapJsonFieldAndPath($value); + + return 'json_extract(' . $field . $path . ')'; + } + + /** + * Split the given JSON selector into the field and the optional path and wrap them separately. + */ + protected function wrapJsonFieldAndPath(string $column): array + { + $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. + */ + protected function wrapJsonPath(string $value, string $delimiter = '->'): string + { + $value = preg_replace("/([\\\\]+)?\\'/", "''", $value); + + $jsonPath = collect(explode($delimiter, $value)) + ->map(fn ($segment) => $this->wrapJsonPathSegment($segment)) + ->implode('.'); + + return "'$" . (str_starts_with($jsonPath, '[') ? '' : '.') . $jsonPath . "'"; + } + + /** + * Wrap the given JSON path segment. + */ + protected function wrapJsonPathSegment(string $segment): string + { + if (preg_match('/(\[[^\]]+\])+$/', $segment, $parts)) { + $key = Str::beforeLast($segment, $parts[0]); + + if (! empty($key)) { + return '"' . $key . '"' . $parts[0]; + } + + return $parts[0]; + } + + return '"' . $segment . '"'; + } + + /** + * Get the SQL for a nullable column modifier. + */ + protected function modifyNullable(Blueprint $blueprint, Fluent $column): ?string + { + if (is_null($column->virtualAs) && is_null($column->storedAs)) { + return $column->nullable ? '' : ' not null'; + } + + if ($column->nullable === false) { + return ' not null'; + } + + return null; + } + + /** + * Get the SQL for a default column modifier. + */ + protected function modifyDefault(Blueprint $blueprint, Fluent $column): ?string + { + if (! is_null($column->default) && is_null($column->virtualAs) && is_null($column->storedAs)) { + return ' default ' . $this->getDefaultValue($column->default); + } + + return null; + } + + /** + * Get the SQL for an auto-increment column modifier. + */ + protected function modifyIncrement(Blueprint $blueprint, Fluent $column): ?string + { + if (in_array($column->type, $this->serials) && $column->autoIncrement) { + return ' primary key autoincrement'; + } + + return null; + } +} diff --git a/src/Schema/SQLiteBuilder.php b/src/Schema/SQLiteBuilder.php new file mode 100644 index 0000000..cce4461 --- /dev/null +++ b/src/Schema/SQLiteBuilder.php @@ -0,0 +1,150 @@ +getFilesystem() + ->put($name, ''); + } + + /** + * Drop a database from the schema if the database exists. + * + * @param string $name + */ + public function dropDatabaseIfExists($name): bool + { + $file = $this->getFilesystem(); + + return $file->exists($name) + ? $file->delete($name) + : true; + } + + /** + * Get the column type listing for a given table. + * + * @param string $table + */ + public function getColumnTypeListing($table): array + { + $table = $this->connection->getTablePrefix() . $table; + + return $this->connection->getPostProcessor()->processColumnListing( + $this->connection->select( + $this->grammar->compileColumnListing($table) + ) + ); + } + + /** + * Get the indexes for a given table. + */ + public function getIndexes(string $table): array + { + $table = $this->connection->getTablePrefix() . $table; + + return $this->connection->getPostProcessor()->processIndexes( + $this->connection->selectFromWriteConnection($this->grammar->compileIndexes($table)) + ); + } + + /** + * Get the names of the indexes for a given table. + */ + public function getIndexListing(string $table): array + { + return array_column($this->getIndexes($table), 'name'); + } + + /** + * Determine if the given table has a given index. + * + * @param array|string $index + */ + public function hasIndex(string $table, $index, string $type = null): bool + { + $type = is_null($type) ? $type : strtolower($type); + + foreach ($this->getIndexes($table) as $value) { + $typeMatches = is_null($type) + || ($type === 'primary' && $value['primary']) + || ($type === 'unique' && $value['unique']) + || $type === $value['type']; + + if (($value['name'] === $index || $value['columns'] === $index) && $typeMatches) { + return true; + } + } + + return false; + } + + /** + * Drop all tables from the database. + */ + public function dropAllTables(): void + { + if ($this->connection->getDatabaseName() !== ':memory:') { + $this->refreshDatabaseFile(); + } + + $this->connection->select($this->grammar->compileEnableWriteableSchema()); + + $this->connection->select($this->grammar->compileDropAllTables()); + + $this->connection->select($this->grammar->compileDisableWriteableSchema()); + + $this->connection->select($this->grammar->compileRebuild()); + } + + /** + * Drop all views from the database. + */ + public function dropAllViews(): void + { + $this->connection->select($this->grammar->compileEnableWriteableSchema()); + + $this->connection->select($this->grammar->compileDropAllViews()); + + $this->connection->select($this->grammar->compileDisableWriteableSchema()); + + $this->connection->select($this->grammar->compileRebuild()); + } + + /** + * Empty the database file. + */ + public function refreshDatabaseFile(): void + { + $this->getFilesystem() + ->put($this->connection->getDatabaseName(), ''); + } + + protected function getFilesystem(): Filesystem + { + return ApplicationContext::getContainer() + ->get(Filesystem::class); + } +} diff --git a/tests/DatabaseSQLiteBuilderTest.php b/tests/DatabaseSQLiteBuilderTest.php new file mode 100644 index 0000000..7caca32 --- /dev/null +++ b/tests/DatabaseSQLiteBuilderTest.php @@ -0,0 +1,99 @@ +shouldReceive('put') + ->once() + ->with('my_temporary_database_a', '') + ->andReturn(20); + + $container = m::mock(ContainerInterface::class); + $container->shouldReceive('get') + ->with(Filesystem::class) + ->andReturn($filesystem); + ApplicationContext::setContainer($container); + + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once(); + + $builder = new SQLiteBuilder($connection); + + $this->assertTrue($builder->createDatabase('my_temporary_database_a')); + + $filesystem->shouldReceive('put') + ->once() + ->with('my_temporary_database_b', '') + ->andReturn(false); + + $this->assertFalse($builder->createDatabase('my_temporary_database_b')); + } + + public function testDropDatabaseIfExists() + { + $filesystem = m::mock(Filesystem::class); + $filesystem->shouldReceive('exists') + ->once() + ->andReturn(true); + + $filesystem->shouldReceive('delete') + ->once() + ->with('my_temporary_database_b') + ->andReturn(true); + + $container = m::mock(ContainerInterface::class); + $container->shouldReceive('get') + ->with(Filesystem::class) + ->andReturn($filesystem); + ApplicationContext::setContainer($container); + + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once(); + + $builder = new SQLiteBuilder($connection); + + $this->assertTrue($builder->dropDatabaseIfExists('my_temporary_database_b')); + + $filesystem->shouldReceive('exists') + ->once() + ->andReturn(false); + + $this->assertTrue($builder->dropDatabaseIfExists('my_temporary_database_c')); + + $filesystem->shouldReceive('exists') + ->once() + ->andReturn(true); + + $filesystem->shouldReceive('delete') + ->once() + ->with('my_temporary_database_c') + ->andReturn(false); + + $this->assertFalse($builder->dropDatabaseIfExists('my_temporary_database_c')); + } +} diff --git a/tests/DatabaseSQLiteProcessorTest.php b/tests/DatabaseSQLiteProcessorTest.php new file mode 100644 index 0000000..7409b5f --- /dev/null +++ b/tests/DatabaseSQLiteProcessorTest.php @@ -0,0 +1,57 @@ + 'id', 'type' => 'INTEGER', 'notnull' => '0', 'default' => '', 'pk' => '1'], + ['name' => 'name', 'type' => 'varchar', 'notnull' => '1', 'default' => 'foo', 'pk' => '0'], + ['name' => 'is_active', 'type' => 'tinyint(1)', 'notnull' => '0', 'default' => '1', 'pk' => '0'], + ]; + $expected = ['id', 'name', 'is_active']; + + $this->assertSame($expected, $processor->processColumnListing($listing)); + } + + public function testProcessColumns() + { + $processor = new SQLiteProcessor(); + + $listing = [ + ['table_name' => 'foo', 'column_name' => 'id', 'type' => 'INTEGER', 'nullable' => '0', 'default' => '', 'primary' => '1', 'cid' => 0], + ['table_name' => 'foo', 'column_name' => 'name', 'type' => 'varchar', 'nullable' => '1', 'default' => 'foo', 'primary' => '0', 'cid' => 1], + ['table_name' => 'foo', 'column_name' => 'is_active', 'type' => 'tinyint(1)', 'nullable' => '0', 'default' => '1', 'primary' => '0', 'cid' => 2], + ]; + + $this->assertSame(3, count($columns = $processor->processColumns($listing))); + $this->assertInstanceOf(Column::class, $column = $columns[0]); + $this->assertSame('foo', $column->getTable()); + $this->assertSame('id', $column->getName()); + $this->assertSame(1, $column->getPosition()); + $this->assertSame('integer', $column->getType()); + $this->assertEmpty($column->getDefault()); + $this->assertFalse($column->isNullable()); + } +} diff --git a/tests/DatabaseSQLiteQueryGrammarTest.php b/tests/DatabaseSQLiteQueryGrammarTest.php new file mode 100644 index 0000000..22a8677 --- /dev/null +++ b/tests/DatabaseSQLiteQueryGrammarTest.php @@ -0,0 +1,45 @@ +shouldReceive('escape')->with('foo', false)->andReturn("'foo'"); + $grammar = new SQLiteGrammar(); + + $bindings = array_map(fn ($value) => $connection->escape($value, false), ['foo']); + + $query = $grammar->substituteBindingsIntoRawSql( + 'select * from "users" where \'Hello\'\'World?\' IS NOT NULL AND "email" = ?', + $bindings, + ); + + $this->assertSame('select * from "users" where \'Hello\'\'World?\' IS NOT NULL AND "email" = \'foo\'', $query); + } +} diff --git a/tests/DatabaseSQLiteSchemaGrammarTest.php b/tests/DatabaseSQLiteSchemaGrammarTest.php new file mode 100644 index 0000000..45275c5 --- /dev/null +++ b/tests/DatabaseSQLiteSchemaGrammarTest.php @@ -0,0 +1,939 @@ +create(); + $blueprint->increments('id'); + $blueprint->string('email'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create table "users" ("id" integer primary key autoincrement not null, "email" varchar not null)', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->increments('id'); + $blueprint->string('email'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(2, $statements); + $expected = [ + 'alter table "users" add column "id" integer primary key autoincrement not null', + 'alter table "users" add column "email" varchar not null', + ]; + $this->assertEquals($expected, $statements); + } + + public function testCreateTemporaryTable() + { + $blueprint = new Blueprint('users'); + $blueprint->create(); + $blueprint->temporary(); + $blueprint->increments('id'); + $blueprint->string('email'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create temporary table "users" ("id" integer primary key autoincrement not null, "email" varchar not null)', $statements[0]); + } + + public function testDropTable() + { + $blueprint = new Blueprint('users'); + $blueprint->drop(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('drop table "users"', $statements[0]); + } + + public function testDropTableIfExists() + { + $blueprint = new Blueprint('users'); + $blueprint->dropIfExists(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('drop table if exists "users"', $statements[0]); + } + + public function testDropUnique() + { + $blueprint = new Blueprint('users'); + $blueprint->dropUnique('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('drop index "foo"', $statements[0]); + } + + public function testDropIndex() + { + $blueprint = new Blueprint('users'); + $blueprint->dropIndex('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('drop index "foo"', $statements[0]); + } + + public function testDropColumn() + { + $blueprint = new Blueprint('users'); + $blueprint->dropColumn('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" drop column "foo"', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->dropColumn(['foo', 'bar']); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(2, $statements); + $this->assertSame('alter table "users" drop column "foo"', $statements[0]); + $this->assertSame('alter table "users" drop column "bar"', $statements[1]); + } + + public function testDropSpatialIndex() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The database driver in use does not support spatial indexes.'); + + $blueprint = new Blueprint('geo'); + $blueprint->dropSpatialIndex(['coordinates']); + $blueprint->toSql($this->getConnection(), $this->getGrammar()); + } + + public function testRenameTable() + { + $blueprint = new Blueprint('users'); + $blueprint->rename('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" rename to "foo"', $statements[0]); + } + + public function testRenameIndex() + { + $pdo = (new SQLiteConnector()) + ->connect([ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => 'prefix_', + ]); + $connection = new SQLiteConnection($pdo); + $resolver = new ConnectionResolver([ + 'default' => $connection, + ]); + + $schema = $resolver->connection()->getSchemaBuilder(); + + $schema->create('users', function (Blueprint $table) { + $table->string('name'); + $table->string('email'); + }); + + $schema->table('users', function (Blueprint $table) { + $table->index(['name', 'email'], 'index1'); + }); + + $indexes = $schema->getIndexListing('users'); + + $this->assertContains('index1', $indexes); + $this->assertNotContains('index2', $indexes); + + $schema->table('users', function (Blueprint $table) { + $table->renameIndex('index1', 'index2'); + }); + + $this->assertFalse($schema->hasIndex('users', 'index1')); + $this->assertTrue(Collection::make($schema->getIndexes('users'))->contains( + fn ($index) => $index['name'] === 'index2' && $index['columns'] === ['name', 'email'] + )); + } + + public function testAddingPrimaryKey() + { + $blueprint = new Blueprint('users'); + $blueprint->create(); + $blueprint->string('foo')->primary(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create table "users" ("foo" varchar not null, primary key ("foo"))', $statements[0]); + } + + public function testAddingForeignKey() + { + $blueprint = new Blueprint('users'); + $blueprint->create(); + $blueprint->string('foo')->primary(); + $blueprint->string('order_id'); + $blueprint->foreign('order_id')->references('id')->on('orders'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + // because string return type in Hyperf\Database\Schema\Grammars\Grammar::compileForeign is a must + // it has a redundancy statement + $this->assertCount(2, $statements); + $this->assertSame('create table "users" ("foo" varchar not null, "order_id" varchar not null, foreign key("order_id") references "orders"("id"), primary key ("foo"))', $statements[0]); + } + + public function testAddingUniqueKey() + { + $blueprint = new Blueprint('users'); + $blueprint->unique('foo', 'bar'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create unique index "bar" on "users" ("foo")', $statements[0]); + } + + public function testAddingIndex() + { + $blueprint = new Blueprint('users'); + $blueprint->index(['foo', 'bar'], 'baz'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create index "baz" on "users" ("foo", "bar")', $statements[0]); + } + + public function testAddingSpatialIndex() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The database driver in use does not support spatial indexes.'); + + $blueprint = new Blueprint('geo'); + $blueprint->spatialIndex('coordinates'); + $blueprint->toSql($this->getConnection(), $this->getGrammar()); + } + + public function testAddingFluentSpatialIndex() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The database driver in use does not support spatial indexes.'); + + $blueprint = new Blueprint('geo'); + $blueprint->point('coordinates')->spatialIndex(); + $blueprint->toSql($this->getConnection(), $this->getGrammar()); + } + + public function testAddingRawIndex() + { + $blueprint = new Blueprint('users'); + $blueprint->rawIndex('(function(column))', 'raw_index'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create index "raw_index" on "users" ((function(column)))', $statements[0]); + } + + public function testAddingIncrementingID() + { + $blueprint = new Blueprint('users'); + $blueprint->increments('id'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "id" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingSmallIncrementingID() + { + $blueprint = new Blueprint('users'); + $blueprint->smallIncrements('id'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "id" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingMediumIncrementingID() + { + $blueprint = new Blueprint('users'); + $blueprint->mediumIncrements('id'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "id" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingID() + { + $blueprint = new Blueprint('users'); + $blueprint->id(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "id" integer primary key autoincrement not null', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->id('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingBigIncrementingID() + { + $blueprint = new Blueprint('users'); + $blueprint->bigIncrements('id'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "id" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingString() + { + $blueprint = new Blueprint('users'); + $blueprint->string('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" varchar not null', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->string('foo', 100); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" varchar not null', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->string('foo', 100)->nullable()->default('bar'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" varchar default \'bar\'', $statements[0]); + } + + public function testAddingText() + { + $blueprint = new Blueprint('users'); + $blueprint->text('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" text not null', $statements[0]); + } + + public function testAddingBigInteger() + { + $blueprint = new Blueprint('users'); + $blueprint->bigInteger('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer not null', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->bigInteger('foo', true); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingInteger() + { + $blueprint = new Blueprint('users'); + $blueprint->integer('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer not null', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->integer('foo', true); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingMediumInteger() + { + $blueprint = new Blueprint('users'); + $blueprint->mediumInteger('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer not null', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->mediumInteger('foo', true); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingTinyInteger() + { + $blueprint = new Blueprint('users'); + $blueprint->tinyInteger('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer not null', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->tinyInteger('foo', true); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingSmallInteger() + { + $blueprint = new Blueprint('users'); + $blueprint->smallInteger('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer not null', $statements[0]); + + $blueprint = new Blueprint('users'); + $blueprint->smallInteger('foo', true); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" integer primary key autoincrement not null', $statements[0]); + } + + public function testAddingFloat() + { + $blueprint = new Blueprint('users'); + $blueprint->float('foo', 5, 2); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" float not null', $statements[0]); + } + + public function testAddingDouble() + { + $blueprint = new Blueprint('users'); + $blueprint->double('foo', 15, 8); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" float not null', $statements[0]); + } + + public function testAddingDecimal() + { + $blueprint = new Blueprint('users'); + $blueprint->decimal('foo', 5, 2); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" numeric not null', $statements[0]); + } + + public function testAddingBoolean() + { + $blueprint = new Blueprint('users'); + $blueprint->boolean('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" tinyint(1) not null', $statements[0]); + } + + public function testAddingEnum() + { + $blueprint = new Blueprint('users'); + $blueprint->enum('role', ['member', 'admin']); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "role" varchar check ("role" in (\'member\', \'admin\')) not null', $statements[0]); + } + + public function testAddingJson() + { + $blueprint = new Blueprint('users'); + $blueprint->json('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" text not null', $statements[0]); + } + + public function testAddingJsonb() + { + $blueprint = new Blueprint('users'); + $blueprint->jsonb('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" text not null', $statements[0]); + } + + public function testAddingDate() + { + $blueprint = new Blueprint('users'); + $blueprint->date('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" date not null', $statements[0]); + } + + public function testAddingYear() + { + $blueprint = new Blueprint('users'); + $blueprint->year('birth_year'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "birth_year" integer not null', $statements[0]); + } + + public function testAddingDateTime() + { + $blueprint = new Blueprint('users'); + $blueprint->dateTime('created_at'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" datetime not null', $statements[0]); + } + + public function testAddingDateTimeWithPrecision() + { + $blueprint = new Blueprint('users'); + $blueprint->dateTime('created_at', 1); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" datetime not null', $statements[0]); + } + + public function testAddingDateTimeTz() + { + $blueprint = new Blueprint('users'); + $blueprint->dateTimeTz('created_at'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" datetime not null', $statements[0]); + } + + public function testAddingDateTimeTzWithPrecision() + { + $blueprint = new Blueprint('users'); + $blueprint->dateTimeTz('created_at', 1); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" datetime not null', $statements[0]); + } + + public function testAddingTime() + { + $blueprint = new Blueprint('users'); + $blueprint->time('created_at'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" time not null', $statements[0]); + } + + public function testAddingTimeWithPrecision() + { + $blueprint = new Blueprint('users'); + $blueprint->time('created_at', 1); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" time not null', $statements[0]); + } + + public function testAddingTimeTz() + { + $blueprint = new Blueprint('users'); + $blueprint->timeTz('created_at'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" time not null', $statements[0]); + } + + public function testAddingTimeTzWithPrecision() + { + $blueprint = new Blueprint('users'); + $blueprint->timeTz('created_at', 1); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" time not null', $statements[0]); + } + + public function testAddingTimestamp() + { + $blueprint = new Blueprint('users'); + $blueprint->timestamp('created_at'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" datetime not null', $statements[0]); + } + + public function testAddingTimestampWithPrecision() + { + $blueprint = new Blueprint('users'); + $blueprint->timestamp('created_at', 1); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" datetime not null', $statements[0]); + } + + public function testAddingTimestampTz() + { + $blueprint = new Blueprint('users'); + $blueprint->timestampTz('created_at'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" datetime not null', $statements[0]); + } + + public function testAddingTimestampTzWithPrecision() + { + $blueprint = new Blueprint('users'); + $blueprint->timestampTz('created_at', 1); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "created_at" datetime not null', $statements[0]); + } + + public function testAddingTimestamps() + { + $blueprint = new Blueprint('users'); + $blueprint->timestamps(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(2, $statements); + $this->assertEquals([ + 'alter table "users" add column "created_at" datetime', + 'alter table "users" add column "updated_at" datetime', + ], $statements); + } + + public function testAddingTimestampsTz() + { + $blueprint = new Blueprint('users'); + $blueprint->timestampsTz(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + $this->assertCount(2, $statements); + $this->assertEquals([ + 'alter table "users" add column "created_at" datetime', + 'alter table "users" add column "updated_at" datetime', + ], $statements); + } + + public function testAddingRememberToken() + { + $blueprint = new Blueprint('users'); + $blueprint->rememberToken(); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "remember_token" varchar', $statements[0]); + } + + public function testAddingBinary() + { + $blueprint = new Blueprint('users'); + $blueprint->binary('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" blob not null', $statements[0]); + } + + public function testAddingUuid() + { + $blueprint = new Blueprint('users'); + $blueprint->uuid('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" varchar not null', $statements[0]); + } + + public function testAddingForeignUuid() + { + $blueprint = new Blueprint('users'); + $foreignUuid = $blueprint->foreignUuid('foo'); + $blueprint->foreignUuid('company_id')->constrained(); + $blueprint->foreignUuid('laravel_idea_id')->constrained(); + $blueprint->foreignUuid('team_id')->references('id')->on('teams'); + $blueprint->foreignUuid('team_column_id')->constrained('teams'); + + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertInstanceOf(ForeignIdColumnDefinition::class, $foreignUuid); + // because string return type in Hyperf\Database\Schema\Grammars\Grammar::compileForeign is a must + // it has a redundancy statement + $this->assertSame(9, count($statements)); + $this->assertSame('alter table "users" add column "foo" varchar not null', $statements[0]); + $this->assertSame('alter table "users" add column "company_id" varchar not null', $statements[1]); + $this->assertSame('alter table "users" add column "laravel_idea_id" varchar not null', $statements[2]); + $this->assertSame('alter table "users" add column "team_id" varchar not null', $statements[3]); + $this->assertSame('alter table "users" add column "team_column_id" varchar not null', $statements[4]); + } + + public function testAddingIpAddress() + { + $blueprint = new Blueprint('users'); + $blueprint->ipAddress('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" varchar not null', $statements[0]); + } + + public function testAddingMacAddress() + { + $blueprint = new Blueprint('users'); + $blueprint->macAddress('foo'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "users" add column "foo" varchar not null', $statements[0]); + } + + public function testAddingGeometry() + { + $blueprint = new Blueprint('geo'); + $blueprint->geometry('coordinates'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "geo" add column "coordinates" geometry not null', $statements[0]); + } + + public function testAddingPoint() + { + $blueprint = new Blueprint('geo'); + $blueprint->point('coordinates'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "geo" add column "coordinates" point not null', $statements[0]); + } + + public function testAddingLineString() + { + $blueprint = new Blueprint('geo'); + $blueprint->linestring('coordinates'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "geo" add column "coordinates" linestring not null', $statements[0]); + } + + public function testAddingPolygon() + { + $blueprint = new Blueprint('geo'); + $blueprint->polygon('coordinates'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "geo" add column "coordinates" polygon not null', $statements[0]); + } + + public function testAddingGeometryCollection() + { + $blueprint = new Blueprint('geo'); + $blueprint->geometrycollection('coordinates'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "geo" add column "coordinates" geometrycollection not null', $statements[0]); + } + + public function testAddingMultiPoint() + { + $blueprint = new Blueprint('geo'); + $blueprint->multipoint('coordinates'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "geo" add column "coordinates" multipoint not null', $statements[0]); + } + + public function testAddingMultiLineString() + { + $blueprint = new Blueprint('geo'); + $blueprint->multilinestring('coordinates'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "geo" add column "coordinates" multilinestring not null', $statements[0]); + } + + public function testAddingMultiPolygon() + { + $blueprint = new Blueprint('geo'); + $blueprint->multipolygon('coordinates'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('alter table "geo" add column "coordinates" multipolygon not null', $statements[0]); + } + + public function testAddingGeneratedColumn() + { + $blueprint = new Blueprint('products'); + $blueprint->create(); + $blueprint->integer('price'); + $blueprint->integer('discounted_virtual')->virtualAs('"price" - 5'); + $blueprint->integer('discounted_stored')->storedAs('"price" - 5'); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(1, $statements); + $this->assertSame('create table "products" ("price" integer not null, "discounted_virtual" integer as ("price" - 5), "discounted_stored" integer as ("price" - 5) stored)', $statements[0]); + + $blueprint = new Blueprint('products'); + $blueprint->integer('price'); + $blueprint->integer('discounted_virtual')->virtualAs('"price" - 5')->nullable(false); + $blueprint->integer('discounted_stored')->storedAs('"price" - 5')->nullable(false); + $statements = $blueprint->toSql($this->getConnection(), $this->getGrammar()); + + $this->assertCount(2, $statements); + $expected = [ + 'alter table "products" add column "price" integer not null', + 'alter table "products" add column "discounted_virtual" integer not null as ("price" - 5)', + ]; + $this->assertSame($expected, $statements); + } + + public function testGrammarsAreMacroable() + { + // compileReplace macro. + $this->getGrammar()::macro('compileReplace', function () { + return true; + }); + + $c = $this->getGrammar()::compileReplace(); + + $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')->virtualAsJson('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 not null 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')->virtualAsJson('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 not null as (json_extract("my_json_column", \'$."some_attribute"."nested"\')))', $statements[0]); + } + + public function testCreateTableWithVirtualAsColumnWhenJsonColumnHasArrayKey() + { + $blueprint = new Blueprint('users'); + $blueprint->create(); + $blueprint->string('my_json_column')->virtualAsJson('my_json_column->foo[0][1]'); + + $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 not null as (json_extract(\"my_json_column\", '$.\"foo\"[0][1]')))", $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')->storedAsJson('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 not null 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')->storedAsJson('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 not null as (json_extract("my_json_column", \'$."some_attribute"."nested"\')) stored)', $statements[0]); + } + + public function getGrammar() + { + return new SQLiteGrammar(); + } + + protected function getConnection() + { + return m::mock(Connection::class); + } +}