diff --git a/.editorconfig b/.editorconfig index 1feb43fc7b..0fb3984996 100644 --- a/.editorconfig +++ b/.editorconfig @@ -23,3 +23,6 @@ indent_size = 2 [*.neon] indent_style = tab + +[{install,update}.php] +indent_size = 2 diff --git a/.github/workflows/REUSABLE_backend.yml b/.github/workflows/REUSABLE_backend.yml index 092356c6b2..32d89306fe 100644 --- a/.github/workflows/REUSABLE_backend.yml +++ b/.github/workflows/REUSABLE_backend.yml @@ -44,7 +44,7 @@ on: description: Versions of databases to test with. Should be array of strings encoded as JSON array type: string required: false - default: '["mysql:5.7", "mysql:8.0.30", "mysql:8.1.0", "mariadb"]' + default: '["mysql:5.7", "mysql:8.0.30", "mysql:8.1.0", "mariadb", "sqlite:3"]' php_ini_values: description: PHP ini values @@ -79,32 +79,49 @@ jobs: # Expands the matrix by naming DBs. - service: 'mysql:5.7' db: MySQL 5.7 + driver: mysql - service: 'mysql:8.0.30' db: MySQL 8.0 + driver: mysql - service: mariadb db: MariaDB + driver: mysql - service: 'mysql:8.1.0' db: MySQL 8.1 + driver: mysql + - service: 'sqlite:3' + db: SQLite + driver: sqlite # Include Database prefix tests with only one PHP version. - php: ${{ fromJSON(inputs.php_versions)[0] }} service: 'mysql:5.7' db: MySQL 5.7 + driver: mysql prefix: flarum_ prefixStr: (prefix) - php: ${{ fromJSON(inputs.php_versions)[0] }} service: 'mysql:8.0.30' db: MySQL 8.0 + driver: mysql prefix: flarum_ prefixStr: (prefix) - php: ${{ fromJSON(inputs.php_versions)[0] }} service: mariadb db: MariaDB + driver: mysql prefix: flarum_ prefixStr: (prefix) - php: ${{ fromJSON(inputs.php_versions)[0] }} service: 'mysql:8.1.0' db: MySQL 8.1 + driver: mysql + prefix: flarum_ + prefixStr: (prefix) + - php: ${{ fromJSON(inputs.php_versions)[0] }} + service: 'sqlite:3' + db: SQLite + driver: sqlite prefix: flarum_ prefixStr: (prefix) @@ -112,10 +129,22 @@ jobs: exclude: - php: ${{ fromJSON(inputs.php_versions)[1] }} service: 'mysql:8.0.30' + - php: ${{ fromJSON(inputs.php_versions)[0] }} + service: mariadb + - php: ${{ fromJSON(inputs.php_versions)[1] }} + service: mariadb + - php: ${{ fromJSON(inputs.php_versions)[0] }} + service: 'mysql:8.1.0' + - php: ${{ fromJSON(inputs.php_versions)[1] }} + service: 'mysql:8.1.0' + - php: ${{ fromJSON(inputs.php_versions)[0] }} + service: 'sqlite:3' + - php: ${{ fromJSON(inputs.php_versions)[1] }} + service: 'sqlite:3' services: mysql: - image: ${{ matrix.service }} + image: ${{ matrix.service != 'sqlite:3' && matrix.service || '' }} ports: - 13306:3306 @@ -138,6 +167,7 @@ jobs: ini-values: ${{ matrix.php_ini_values }} - name: Create MySQL Database + if: ${{ matrix.service != 'sqlite:3' }} run: | sudo systemctl start mysql mysql -uroot -proot -e 'CREATE DATABASE flarum_test;' --port 13306 @@ -167,6 +197,7 @@ jobs: DB_PORT: 13306 DB_PASSWORD: root DB_PREFIX: ${{ matrix.prefix }} + DB_DRIVER: ${{ matrix.driver }} COMPOSER_PROCESS_TIMEOUT: 600 phpstan: diff --git a/extensions/flags/src/Flag.php b/extensions/flags/src/Flag.php index 1fd01dad77..c9050a8ba9 100644 --- a/extensions/flags/src/Flag.php +++ b/extensions/flags/src/Flag.php @@ -14,6 +14,7 @@ use Flarum\Database\ScopeVisibilityTrait; use Flarum\Post\Post; use Flarum\User\User; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** @@ -30,6 +31,7 @@ class Flag extends AbstractModel { use ScopeVisibilityTrait; + use HasFactory; protected $casts = ['created_at' => 'datetime']; diff --git a/extensions/flags/src/FlagFactory.php b/extensions/flags/src/FlagFactory.php new file mode 100644 index 0000000000..c73106528b --- /dev/null +++ b/extensions/flags/src/FlagFactory.php @@ -0,0 +1,30 @@ + 'user', + 'post_id' => Post::factory(), + 'user_id' => User::factory(), + 'reason' => $this->faker->sentence, + 'reason_detail' => $this->faker->sentence, + 'created_at' => Carbon::now(), + ]; + } +} diff --git a/extensions/flags/tests/integration/api/flags/ListTest.php b/extensions/flags/tests/integration/api/flags/ListTest.php index fb2c75171b..ef4f17b293 100644 --- a/extensions/flags/tests/integration/api/flags/ListTest.php +++ b/extensions/flags/tests/integration/api/flags/ListTest.php @@ -10,6 +10,7 @@ namespace Flarum\Flags\Tests\integration\api\flags; use Flarum\Discussion\Discussion; +use Flarum\Flags\Flag; use Flarum\Group\Group; use Flarum\Post\Post; use Flarum\Testing\integration\RetrievesAuthorizedUsers; @@ -55,7 +56,7 @@ protected function setUp(): void ['id' => 2, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '

'], ['id' => 3, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '

'], ], - 'flags' => [ + Flag::class => [ ['id' => 1, 'post_id' => 1, 'user_id' => 1], ['id' => 2, 'post_id' => 1, 'user_id' => 2], ['id' => 3, 'post_id' => 1, 'user_id' => 3], diff --git a/extensions/flags/tests/integration/api/flags/ListWithTagsTest.php b/extensions/flags/tests/integration/api/flags/ListWithTagsTest.php index 7bd38b544a..ab65c66fec 100644 --- a/extensions/flags/tests/integration/api/flags/ListWithTagsTest.php +++ b/extensions/flags/tests/integration/api/flags/ListWithTagsTest.php @@ -10,6 +10,7 @@ namespace Flarum\Flags\Tests\integration\api\flags; use Flarum\Discussion\Discussion; +use Flarum\Flags\Flag; use Flarum\Group\Group; use Flarum\Post\Post; use Flarum\Tags\Tag; @@ -83,7 +84,7 @@ protected function setUp(): void ['id' => 6, 'discussion_id' => 4, 'user_id' => 1, 'type' => 'comment', 'content' => '

'], ['id' => 7, 'discussion_id' => 5, 'user_id' => 1, 'type' => 'comment', 'content' => '

'], ], - 'flags' => [ + Flag::class => [ // From regular ListTest ['id' => 1, 'post_id' => 1, 'user_id' => 1], ['id' => 2, 'post_id' => 1, 'user_id' => 2], diff --git a/extensions/mentions/migrations/2022_05_20_000005_add_created_at_to_post_mentions_post_table.php b/extensions/mentions/migrations/2022_05_20_000005_add_created_at_to_post_mentions_post_table.php index d6c4114e1d..56c60aa2c3 100644 --- a/extensions/mentions/migrations/2022_05_20_000005_add_created_at_to_post_mentions_post_table.php +++ b/extensions/mentions/migrations/2022_05_20_000005_add_created_at_to_post_mentions_post_table.php @@ -16,10 +16,9 @@ $table->timestamp('created_at')->nullable(); }); - // do this manually because dbal doesn't recognize timestamp columns - $connection = $schema->getConnection(); - $prefix = $connection->getTablePrefix(); - $connection->statement("ALTER TABLE `{$prefix}post_mentions_post` MODIFY created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP"); + $schema->table('post_mentions_post', function (Blueprint $table) { + $table->timestamp('created_at')->nullable()->useCurrent()->change(); + }); }, 'down' => function (Builder $schema) { diff --git a/extensions/mentions/migrations/2022_05_20_000006_add_created_at_to_post_mentions_user_table.php b/extensions/mentions/migrations/2022_05_20_000006_add_created_at_to_post_mentions_user_table.php index f2991d6074..7361a64364 100644 --- a/extensions/mentions/migrations/2022_05_20_000006_add_created_at_to_post_mentions_user_table.php +++ b/extensions/mentions/migrations/2022_05_20_000006_add_created_at_to_post_mentions_user_table.php @@ -16,10 +16,9 @@ $table->timestamp('created_at')->nullable(); }); - // do this manually because dbal doesn't recognize timestamp columns - $connection = $schema->getConnection(); - $prefix = $connection->getTablePrefix(); - $connection->statement("ALTER TABLE `{$prefix}post_mentions_user` MODIFY created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP"); + $schema->table('post_mentions_user', function (Blueprint $table) { + $table->timestamp('created_at')->nullable()->useCurrent()->change(); + }); }, 'down' => function (Builder $schema) { diff --git a/extensions/mentions/src/Api/LoadMentionedByRelationship.php b/extensions/mentions/src/Api/LoadMentionedByRelationship.php index 47bceafb7a..905d7d48cd 100644 --- a/extensions/mentions/src/Api/LoadMentionedByRelationship.php +++ b/extensions/mentions/src/Api/LoadMentionedByRelationship.php @@ -32,7 +32,7 @@ public static function mutateRelation(BelongsToMany $query, ServerRequestInterfa $query ->with(['mentionsPosts', 'mentionsPosts.user', 'mentionsPosts.discussion', 'mentionsUsers']) ->whereVisibleTo($actor) - ->oldest() + ->oldest('posts.created_at') // Limiting a relationship results is only possible because // the Post model uses the \Staudenmeir\EloquentEagerLimit\HasEagerLimit // trait. diff --git a/extensions/statistics/src/Api/Controller/ShowStatisticsData.php b/extensions/statistics/src/Api/Controller/ShowStatisticsData.php index 9319a6d1bb..7f5bb5b374 100644 --- a/extensions/statistics/src/Api/Controller/ShowStatisticsData.php +++ b/extensions/statistics/src/Api/Controller/ShowStatisticsData.php @@ -130,12 +130,16 @@ private function getTimedCounts(Builder $query, string $column, ?DateTime $start $endDate = new DateTime(); } + // if within the last 24 hours, group by hour + $format = 'CASE WHEN '.$column.' > ? THEN \'%Y-%m-%d %H:00:00\' ELSE \'%Y-%m-%d\' END'; + $dbFormattedDatetime = match ($query->getConnection()->getDriverName()) { + 'sqlite' => 'strftime('.$format.', '.$column.')', + default => 'DATE_FORMAT('.$column.', '.$format.')', + }; + $results = $query ->selectRaw( - 'DATE_FORMAT( - @date := '.$column.', - IF(@date > ?, \'%Y-%m-%d %H:00:00\', \'%Y-%m-%d\') -- if within the last 24 hours, group by hour - ) as time_group', + $dbFormattedDatetime.' as time_group', [new DateTime('-25 hours')] ) ->selectRaw('COUNT(id) as count') diff --git a/extensions/statistics/tests/integration/api/CanRequestCustomTimedStatisticsTest.php b/extensions/statistics/tests/integration/api/CanRequestCustomTimedStatisticsTest.php index 536f77edc5..adf5a89b03 100644 --- a/extensions/statistics/tests/integration/api/CanRequestCustomTimedStatisticsTest.php +++ b/extensions/statistics/tests/integration/api/CanRequestCustomTimedStatisticsTest.php @@ -102,7 +102,7 @@ public function can_request_timed_stats() $body = json_decode($response->getBody()->getContents(), true); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $body['errors'][0]['detail'] ?? ''); $this->assertEquals( $data, diff --git a/extensions/sticky/src/PinStickiedDiscussionsToTop.php b/extensions/sticky/src/PinStickiedDiscussionsToTop.php index c8a044216c..a8b57eebc0 100755 --- a/extensions/sticky/src/PinStickiedDiscussionsToTop.php +++ b/extensions/sticky/src/PinStickiedDiscussionsToTop.php @@ -12,6 +12,7 @@ use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\SearchCriteria; use Flarum\Tags\Search\Filter\TagFilter; +use Illuminate\Database\Query\Builder; class PinStickiedDiscussionsToTop { @@ -45,22 +46,26 @@ public function __invoke(DatabaseSearchState $state, SearchCriteria $criteria): $sticky->where('is_sticky', true); unset($sticky->orders); - $query->union($sticky); + /** @var Builder $q */ + foreach ([$sticky, $query] as $q) { + $read = $q->newQuery() + ->selectRaw('1') + ->from('discussion_user as sticky') + ->whereColumn('sticky.discussion_id', 'id') + ->where('sticky.user_id', '=', $state->getActor()->id) + ->whereColumn('sticky.last_read_post_number', '>=', 'last_post_number'); + + // Add the bindings manually (rather than as the second + // argument in orderByRaw) for now due to a bug in Laravel which + // would add the bindings in the wrong order. + $q->selectRaw('(is_sticky and not exists ('.$read->toSql().') and last_posted_at > ?) as is_unread_sticky', array_merge($read->getBindings(), [$state->getActor()->marked_all_as_read_at ?: 0])); + } - $read = $query->newQuery() - ->selectRaw('1') - ->from('discussion_user as sticky') - ->whereColumn('sticky.discussion_id', 'id') - ->where('sticky.user_id', '=', $state->getActor()->id) - ->whereColumn('sticky.last_read_post_number', '>=', 'last_post_number'); + $query->union($sticky); - // Add the bindings manually (rather than as the second - // argument in orderByRaw) for now due to a bug in Laravel which - // would add the bindings in the wrong order. - $query->orderByRaw('is_sticky and not exists ('.$read->toSql().') and last_posted_at > ? desc') - ->addBinding(array_merge($read->getBindings(), [$state->getActor()->marked_all_as_read_at ?: 0]), 'union'); + $query->orderByDesc('is_unread_sticky'); - $query->unionOrders = array_merge($query->unionOrders, $query->orders); + $query->unionOrders = array_merge($query->unionOrders ?? [], $query->orders ?? []); $query->unionLimit = $query->limit; $query->unionOffset = $query->offset; diff --git a/extensions/tags/migrations/2018_06_27_085200_change_tags_columns.php b/extensions/tags/migrations/2018_06_27_085200_change_tags_columns.php index 00cccbbd83..fc5f3793e2 100644 --- a/extensions/tags/migrations/2018_06_27_085200_change_tags_columns.php +++ b/extensions/tags/migrations/2018_06_27_085200_change_tags_columns.php @@ -14,9 +14,14 @@ 'up' => function (Builder $schema) { $schema->table('tags', function (Blueprint $table) { $table->renameColumn('discussions_count', 'discussion_count'); + }); + $schema->table('tags', function (Blueprint $table) { $table->renameColumn('last_time', 'last_posted_at'); + }); + $schema->table('tags', function (Blueprint $table) { $table->renameColumn('last_discussion_id', 'last_posted_discussion_id'); - + }); + $schema->table('tags', function (Blueprint $table) { $table->integer('parent_id')->unsigned()->nullable()->change(); $table->integer('last_posted_user_id')->unsigned()->nullable(); @@ -26,11 +31,17 @@ 'down' => function (Builder $schema) { $schema->table('tags', function (Blueprint $table) { $table->dropColumn('last_posted_user_id'); - + }); + $schema->table('tags', function (Blueprint $table) { $table->integer('parent_id')->nullable()->change(); - + }); + $schema->table('tags', function (Blueprint $table) { $table->renameColumn('discussion_count', 'discussions_count'); + }); + $schema->table('tags', function (Blueprint $table) { $table->renameColumn('last_posted_at', 'last_time'); + }); + $schema->table('tags', function (Blueprint $table) { $table->renameColumn('last_posted_discussion_id', 'last_discussion_id'); }); } diff --git a/extensions/tags/migrations/2022_05_20_000003_add_timestamps_to_tags_table.php b/extensions/tags/migrations/2022_05_20_000003_add_timestamps_to_tags_table.php index b393ae8aa0..86d8ad124e 100644 --- a/extensions/tags/migrations/2022_05_20_000003_add_timestamps_to_tags_table.php +++ b/extensions/tags/migrations/2022_05_20_000003_add_timestamps_to_tags_table.php @@ -17,17 +17,15 @@ $table->timestamp('updated_at')->nullable(); }); - // do this manually because dbal doesn't recognize timestamp columns - $connection = $schema->getConnection(); - $prefix = $connection->getTablePrefix(); - $connection->statement("ALTER TABLE `{$prefix}tags` MODIFY created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP"); - $connection->statement("ALTER TABLE `{$prefix}tags` MODIFY updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"); + $schema->table('tags', function (Blueprint $table) { + $table->timestamp('created_at')->nullable()->useCurrent()->change(); + $table->timestamp('updated_at')->nullable()->useCurrent()->useCurrentOnUpdate()->change(); + }); }, 'down' => function (Builder $schema) { $schema->table('tags', function (Blueprint $table) { - $table->dropColumn('created_at'); - $table->dropColumn('updated_at'); + $table->dropColumn('created_at', 'updated_at'); }); } ]; diff --git a/extensions/tags/migrations/2022_05_20_000004_add_created_at_to_discussion_tag_table.php b/extensions/tags/migrations/2022_05_20_000004_add_created_at_to_discussion_tag_table.php index da0197a6da..314b74ded1 100644 --- a/extensions/tags/migrations/2022_05_20_000004_add_created_at_to_discussion_tag_table.php +++ b/extensions/tags/migrations/2022_05_20_000004_add_created_at_to_discussion_tag_table.php @@ -16,10 +16,9 @@ $table->timestamp('created_at')->nullable(); }); - // do this manually because dbal doesn't recognize timestamp columns - $connection = $schema->getConnection(); - $prefix = $connection->getTablePrefix(); - $connection->statement("ALTER TABLE `{$prefix}discussion_tag` MODIFY created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP"); + $schema->table('discussion_tag', function (Blueprint $table) { + $table->timestamp('created_at')->nullable()->useCurrent()->change(); + }); }, 'down' => function (Builder $schema) { diff --git a/framework/core/js/src/admin/components/StatusWidget.js b/framework/core/js/src/admin/components/StatusWidget.js index ee6dc3ebf6..9f006b1de2 100644 --- a/framework/core/js/src/admin/components/StatusWidget.js +++ b/framework/core/js/src/admin/components/StatusWidget.js @@ -34,7 +34,7 @@ export default class StatusWidget extends DashboardWidget { items.add('version-flarum', [Flarum,
, app.forum.attribute('version')], 100); items.add('version-php', [PHP,
, app.data.phpVersion], 90); - items.add('version-mysql', [MySQL,
, app.data.mysqlVersion], 80); + items.add('version-db', [{app.data.dbDriver},
, app.data.dbVersion], 80); if (app.data.schedulerStatus) { items.add( 'schedule-status', diff --git a/framework/core/migrations/2015_02_24_000000_create_posts_table.php b/framework/core/migrations/2015_02_24_000000_create_posts_table.php index 47c4bb0f73..412fe8d983 100644 --- a/framework/core/migrations/2015_02_24_000000_create_posts_table.php +++ b/framework/core/migrations/2015_02_24_000000_create_posts_table.php @@ -34,7 +34,10 @@ $connection = $schema->getConnection(); $prefix = $connection->getTablePrefix(); - $connection->statement('ALTER TABLE '.$prefix.'posts ADD FULLTEXT content (content)'); + + if ($connection->getDriverName() !== 'sqlite') { + $connection->statement('ALTER TABLE '.$prefix.'posts ADD FULLTEXT content (content)'); + } }, 'down' => function (Builder $schema) { diff --git a/framework/core/migrations/2015_12_05_042721_change_access_tokens_columns.php b/framework/core/migrations/2015_12_05_042721_change_access_tokens_columns.php index 6b5a5a0871..5ba7daff20 100644 --- a/framework/core/migrations/2015_12_05_042721_change_access_tokens_columns.php +++ b/framework/core/migrations/2015_12_05_042721_change_access_tokens_columns.php @@ -12,10 +12,12 @@ return [ 'up' => function (Builder $schema) { + $schema->table('access_tokens', function (Blueprint $table) { + $table->dropColumn('created_at', 'expires_at'); + }); + $schema->table('access_tokens', function (Blueprint $table) { $table->string('id', 40)->change(); - $table->dropColumn('created_at'); - $table->dropColumn('expires_at'); $table->integer('last_activity'); $table->integer('lifetime'); }); @@ -24,8 +26,7 @@ 'down' => function (Builder $schema) { $schema->table('access_tokens', function (Blueprint $table) { $table->string('id', 100)->change(); - $table->dropColumn('last_activity'); - $table->dropColumn('lifetime'); + $table->dropColumn('last_activity', 'lifetime'); $table->timestamp('created_at'); $table->timestamp('expires_at'); }); diff --git a/framework/core/migrations/2018_01_11_093900_change_access_tokens_columns.php b/framework/core/migrations/2018_01_11_093900_change_access_tokens_columns.php index ebf66e593a..303f2027fe 100644 --- a/framework/core/migrations/2018_01_11_093900_change_access_tokens_columns.php +++ b/framework/core/migrations/2018_01_11_093900_change_access_tokens_columns.php @@ -14,14 +14,20 @@ 'up' => function (Builder $schema) { $schema->table('access_tokens', function (Blueprint $table) { $table->renameColumn('id', 'token'); + }); + $schema->table('access_tokens', function (Blueprint $table) { $table->renameColumn('lifetime', 'lifetime_seconds'); + }); + $schema->table('access_tokens', function (Blueprint $table) { $table->renameColumn('last_activity', 'last_activity_at'); + }); + $schema->table('access_tokens', function (Blueprint $table) { $table->dateTime('created_at'); $table->integer('user_id')->unsigned()->change(); }); // Use a separate schema instance because this column gets renamed - // in the first one. + // in the previous one. $schema->table('access_tokens', function (Blueprint $table) { $table->dateTime('last_activity_at')->change(); }); @@ -31,12 +37,19 @@ $schema->table('access_tokens', function (Blueprint $table) { $table->integer('last_activity_at')->change(); }); - $schema->table('access_tokens', function (Blueprint $table) { $table->renameColumn('token', 'id'); + }); + $schema->table('access_tokens', function (Blueprint $table) { $table->renameColumn('lifetime_seconds', 'lifetime'); + }); + $schema->table('access_tokens', function (Blueprint $table) { $table->renameColumn('last_activity_at', 'last_activity'); + }); + $schema->table('access_tokens', function (Blueprint $table) { $table->dropColumn('created_at'); + }); + $schema->table('access_tokens', function (Blueprint $table) { $table->integer('user_id')->change(); }); } diff --git a/framework/core/migrations/2018_01_11_095000_change_api_keys_columns.php b/framework/core/migrations/2018_01_11_095000_change_api_keys_columns.php index 421db10125..298213a869 100644 --- a/framework/core/migrations/2018_01_11_095000_change_api_keys_columns.php +++ b/framework/core/migrations/2018_01_11_095000_change_api_keys_columns.php @@ -12,13 +12,7 @@ return [ 'up' => function (Builder $schema) { - $schema->table('api_keys', function (Blueprint $table) { - $table->dropPrimary(['id']); - $table->renameColumn('id', 'key'); - $table->unique('key'); - }); - - $schema->table('api_keys', function (Blueprint $table) { + $definition = function (Blueprint $table) { $table->increments('id'); $table->string('allowed_ips')->nullable(); $table->string('scopes')->nullable(); @@ -27,7 +21,23 @@ $table->dateTime('last_activity_at')->nullable(); $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); - }); + }; + + if ($schema->getConnection()->getDriverName() !== 'sqlite') { + $schema->table('api_keys', function (Blueprint $table) { + $table->dropPrimary(['id']); + $table->renameColumn('id', 'key'); + $table->unique('key'); + }); + + $schema->table('api_keys', $definition); + } else { + $schema->drop('api_keys'); + $schema->create('api_keys', function (Blueprint $table) use ($definition) { + $table->string('key', 100)->unique(); + $definition($table); + }); + } }, 'down' => function (Builder $schema) { @@ -36,10 +46,13 @@ $table->dropColumn('id', 'allowed_ips', 'user_id', 'scopes', 'created_at'); }); - $schema->table('api_keys', function (Blueprint $table) { + $schema->table('api_keys', function (Blueprint $table) use ($schema) { $table->dropUnique(['key']); $table->renameColumn('key', 'id'); - $table->primary('id'); + + if ($schema->getConnection()->getDriverName() !== 'sqlite') { + $table->primary('id'); + } }); } ]; diff --git a/framework/core/migrations/2018_01_11_102100_change_registration_tokens_created_at_to_datetime.php b/framework/core/migrations/2018_01_11_102100_change_registration_tokens_created_at_to_datetime.php index a01d9d70cc..f487a257ba 100644 --- a/framework/core/migrations/2018_01_11_102100_change_registration_tokens_created_at_to_datetime.php +++ b/framework/core/migrations/2018_01_11_102100_change_registration_tokens_created_at_to_datetime.php @@ -7,19 +7,19 @@ * LICENSE file that was distributed with this source code. */ +use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Builder; return [ 'up' => function (Builder $schema) { - // do this manually because dbal doesn't recognize timestamp columns - $connection = $schema->getConnection(); - $prefix = $connection->getTablePrefix(); - $connection->statement("ALTER TABLE {$prefix}registration_tokens MODIFY created_at DATETIME"); + $schema->table('registration_tokens', function (Blueprint $table) { + $table->dateTime('created_at')->change(); + }); }, 'down' => function (Builder $schema) { - $connection = $schema->getConnection(); - $prefix = $connection->getTablePrefix(); - $connection->statement("ALTER TABLE {$prefix}registration_tokens MODIFY created_at TIMESTAMP"); + $schema->table('registration_tokens', function (Blueprint $table) { + $table->timestamp('created_at')->change(); + }); } ]; diff --git a/framework/core/migrations/2018_01_11_120604_change_posts_table_to_innodb.php b/framework/core/migrations/2018_01_11_120604_change_posts_table_to_innodb.php index 500e5f79bb..2cf3cadfa4 100644 --- a/framework/core/migrations/2018_01_11_120604_change_posts_table_to_innodb.php +++ b/framework/core/migrations/2018_01_11_120604_change_posts_table_to_innodb.php @@ -12,13 +12,19 @@ return [ 'up' => function (Builder $schema) { $connection = $schema->getConnection(); - $prefix = $connection->getTablePrefix(); - $connection->statement('ALTER TABLE '.$prefix.'posts ENGINE = InnoDB'); + + if ($connection->getDriverName() === 'mysql') { + $prefix = $connection->getTablePrefix(); + $connection->statement('ALTER TABLE '.$prefix.'posts ENGINE = InnoDB'); + } }, 'down' => function (Builder $schema) { $connection = $schema->getConnection(); - $prefix = $connection->getTablePrefix(); - $connection->statement('ALTER TABLE '.$prefix.'posts ENGINE = MyISAM'); + + if ($connection->getDriverName() === 'mysql') { + $prefix = $connection->getTablePrefix(); + $connection->statement('ALTER TABLE '.$prefix.'posts ENGINE = MyISAM'); + } } ]; diff --git a/framework/core/migrations/2018_01_15_072800_change_email_tokens_created_at_to_datetime.php b/framework/core/migrations/2018_01_15_072800_change_email_tokens_created_at_to_datetime.php index e97fe64d0e..e08875beab 100644 --- a/framework/core/migrations/2018_01_15_072800_change_email_tokens_created_at_to_datetime.php +++ b/framework/core/migrations/2018_01_15_072800_change_email_tokens_created_at_to_datetime.php @@ -7,19 +7,19 @@ * LICENSE file that was distributed with this source code. */ +use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Builder; return [ 'up' => function (Builder $schema) { - // do this manually because dbal doesn't recognize timestamp columns - $connection = $schema->getConnection(); - $prefix = $connection->getTablePrefix(); - $connection->statement("ALTER TABLE {$prefix}email_tokens MODIFY created_at DATETIME"); + $schema->table('email_tokens', function (Blueprint $table) { + $table->dateTime('created_at')->change(); + }); }, 'down' => function (Builder $schema) { - $connection = $schema->getConnection(); - $prefix = $connection->getTablePrefix(); - $connection->statement("ALTER TABLE {$prefix}email_tokens MODIFY created_at TIMESTAMP"); + $schema->table('email_tokens', function (Blueprint $table) { + $table->timestamp('created_at')->change(); + }); } ]; diff --git a/framework/core/migrations/2018_01_18_133000_change_notifications_columns.php b/framework/core/migrations/2018_01_18_133000_change_notifications_columns.php index 34066ff232..24a6e7ce19 100644 --- a/framework/core/migrations/2018_01_18_133000_change_notifications_columns.php +++ b/framework/core/migrations/2018_01_18_133000_change_notifications_columns.php @@ -15,10 +15,14 @@ 'up' => function (Builder $schema) { $schema->table('notifications', function (Blueprint $table) { $table->dropColumn('subject_type'); - + }); + $schema->table('notifications', function (Blueprint $table) { $table->renameColumn('time', 'created_at'); + }); + $schema->table('notifications', function (Blueprint $table) { $table->renameColumn('sender_id', 'from_user_id'); - + }); + $schema->table('notifications', function (Blueprint $table) { $table->dateTime('read_at')->nullable(); }); @@ -36,8 +40,11 @@ $table->string('subject_type', 200)->nullable(); $table->renameColumn('created_at', 'time'); + }); + $schema->table('notifications', function (Blueprint $table) { $table->renameColumn('from_user_id', 'sender_id'); - + }); + $schema->table('notifications', function (Blueprint $table) { $table->boolean('is_read'); }); diff --git a/framework/core/migrations/2018_01_18_134600_change_password_tokens_created_at_to_datetime.php b/framework/core/migrations/2018_01_18_134600_change_password_tokens_created_at_to_datetime.php index 940929de93..f3c73dc025 100644 --- a/framework/core/migrations/2018_01_18_134600_change_password_tokens_created_at_to_datetime.php +++ b/framework/core/migrations/2018_01_18_134600_change_password_tokens_created_at_to_datetime.php @@ -7,19 +7,19 @@ * LICENSE file that was distributed with this source code. */ +use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Builder; return [ 'up' => function (Builder $schema) { - // do this manually because dbal doesn't recognize timestamp columns - $connection = $schema->getConnection(); - $prefix = $connection->getTablePrefix(); - $connection->statement("ALTER TABLE {$prefix}password_tokens MODIFY created_at DATETIME"); + $schema->table('password_tokens', function (Blueprint $table) { + $table->dateTime('created_at')->change(); + }); }, 'down' => function (Builder $schema) { - $connection = $schema->getConnection(); - $prefix = $connection->getTablePrefix(); - $connection->statement("ALTER TABLE {$prefix}password_tokens MODIFY created_at TIMESTAMP"); + $schema->table('password_tokens', function (Blueprint $table) { + $table->timestamp('created_at')->change(); + }); } ]; diff --git a/framework/core/migrations/2018_01_30_112238_add_fulltext_index_to_discussions_title.php b/framework/core/migrations/2018_01_30_112238_add_fulltext_index_to_discussions_title.php index b2ed1a5daf..e49130d5b6 100644 --- a/framework/core/migrations/2018_01_30_112238_add_fulltext_index_to_discussions_title.php +++ b/framework/core/migrations/2018_01_30_112238_add_fulltext_index_to_discussions_title.php @@ -13,7 +13,10 @@ 'up' => function (Builder $schema) { $connection = $schema->getConnection(); $prefix = $connection->getTablePrefix(); - $connection->statement('ALTER TABLE '.$prefix.'discussions ADD FULLTEXT title (title)'); + + if ($connection->getDriverName() !== 'sqlite') { + $connection->statement('ALTER TABLE '.$prefix.'discussions ADD FULLTEXT title (title)'); + } }, 'down' => function (Builder $schema) { diff --git a/framework/core/migrations/2021_03_02_040500_change_access_tokens_add_id.php b/framework/core/migrations/2021_03_02_040500_change_access_tokens_add_id.php index 06d1ec2486..3bdc520406 100644 --- a/framework/core/migrations/2021_03_02_040500_change_access_tokens_add_id.php +++ b/framework/core/migrations/2021_03_02_040500_change_access_tokens_add_id.php @@ -12,24 +12,42 @@ return [ 'up' => function (Builder $schema) { - $schema->table('access_tokens', function (Blueprint $table) { - // Replace primary key with unique index so we can create a new primary - $table->dropPrimary('token'); - $table->unique('token'); - }); + if ($schema->getConnection()->getDriverName() !== 'sqlite') { + $schema->table('access_tokens', function (Blueprint $table) { + // Replace primary key with unique index so we can create a new primary + $table->dropPrimary('token'); + $table->unique('token'); + }); - // This needs to be done in a second statement because of the order Laravel runs operations in - $schema->table('access_tokens', function (Blueprint $table) { - // Introduce new increment-based ID - $table->increments('id')->first(); - }); + // This needs to be done in a second statement because of the order Laravel runs operations in + $schema->table('access_tokens', function (Blueprint $table) { + // Introduce new increment-based ID + $table->increments('id')->first(); + }); + } else { + $schema->drop('access_tokens'); + $schema->create('access_tokens', function (Blueprint $table) { + $table->increments('id'); + $table->string('token', 100)->unique(); + $table->integer('user_id')->unsigned(); + $table->dateTime('last_activity_at')->nullable(); + $table->dateTime('created_at'); + $table->string('type', 100)->nullable(); + + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->index('type'); + }); + } }, 'down' => function (Builder $schema) { - $schema->table('access_tokens', function (Blueprint $table) { + $schema->table('access_tokens', function (Blueprint $table) use ($schema) { $table->dropColumn('id'); $table->dropIndex('token'); - $table->primary('token'); + + if ($schema->getConnection()->getDriverName() !== 'sqlite') { + $table->primary('token'); + } }); } ]; diff --git a/framework/core/migrations/2022_05_20_000000_add_timestamps_to_groups_table.php b/framework/core/migrations/2022_05_20_000000_add_timestamps_to_groups_table.php index bab2b4a575..169f27c696 100644 --- a/framework/core/migrations/2022_05_20_000000_add_timestamps_to_groups_table.php +++ b/framework/core/migrations/2022_05_20_000000_add_timestamps_to_groups_table.php @@ -17,17 +17,15 @@ $table->timestamp('updated_at')->nullable(); }); - // do this manually because dbal doesn't recognize timestamp columns - $connection = $schema->getConnection(); - $prefix = $connection->getTablePrefix(); - $connection->statement("ALTER TABLE `${prefix}groups` MODIFY created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP"); - $connection->statement("ALTER TABLE `${prefix}groups` MODIFY updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"); + $schema->table('groups', function (Blueprint $table) { + $table->timestamp('created_at')->nullable()->useCurrent()->change(); + $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate()->change(); + }); }, 'down' => function (Builder $schema) { $schema->table('groups', function (Blueprint $table) { - $table->dropColumn('created_at'); - $table->dropColumn('updated_at'); + $table->dropColumn('created_at', 'updated_at'); }); } ]; diff --git a/framework/core/migrations/2022_05_20_000001_add_created_at_to_group_user_table.php b/framework/core/migrations/2022_05_20_000001_add_created_at_to_group_user_table.php index 36bf772655..c0d6ad41cf 100644 --- a/framework/core/migrations/2022_05_20_000001_add_created_at_to_group_user_table.php +++ b/framework/core/migrations/2022_05_20_000001_add_created_at_to_group_user_table.php @@ -16,10 +16,9 @@ $table->timestamp('created_at')->nullable(); }); - // do this manually because dbal doesn't recognize timestamp columns - $connection = $schema->getConnection(); - $prefix = $connection->getTablePrefix(); - $connection->statement("ALTER TABLE `${prefix}group_user` MODIFY created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP"); + $schema->table('group_user', function (Blueprint $table) { + $table->timestamp('created_at')->nullable()->useCurrent()->change(); + }); }, 'down' => function (Builder $schema) { diff --git a/framework/core/migrations/2022_05_20_000002_add_created_at_to_group_permission_table.php b/framework/core/migrations/2022_05_20_000002_add_created_at_to_group_permission_table.php index 1333128f2e..02ea4712d6 100644 --- a/framework/core/migrations/2022_05_20_000002_add_created_at_to_group_permission_table.php +++ b/framework/core/migrations/2022_05_20_000002_add_created_at_to_group_permission_table.php @@ -16,10 +16,9 @@ $table->timestamp('created_at')->nullable(); }); - // do this manually because dbal doesn't recognize timestamp columns - $connection = $schema->getConnection(); - $prefix = $connection->getTablePrefix(); - $connection->statement("ALTER TABLE `${prefix}group_permission` MODIFY created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP"); + $schema->table('group_permission', function (Blueprint $table) { + $table->timestamp('created_at')->nullable()->useCurrent()->change(); + }); }, 'down' => function (Builder $schema) { diff --git a/framework/core/migrations/2024_05_05_000000_add_sqlite_keys.php b/framework/core/migrations/2024_05_05_000000_add_sqlite_keys.php new file mode 100644 index 0000000000..0405a08aa4 --- /dev/null +++ b/framework/core/migrations/2024_05_05_000000_add_sqlite_keys.php @@ -0,0 +1,47 @@ + function (Builder $schema) { + if ($schema->getConnection()->getDriverName() === 'sqlite') { + $schema->getConnection()->statement('PRAGMA foreign_keys = OFF'); + $schema->getConnection()->statement('PRAGMA writable_schema = ON'); + + $prefix = $schema->getConnection()->getTablePrefix(); + + $foreignKeysSqlite = [ + 'discussions' => << <<getConnection()->select('SELECT sql FROM sqlite_master WHERE type = "table" AND name = "'.$prefix.$table.'"')[0]->sql; + $modifiedTableDefinition = str($tableDefinition)->beforeLast(')')->append(', '.$foreignKeysSqlite[$table].')')->toString(); + $modifiedTableDefinitionWithEscapedQuotes = str($modifiedTableDefinition)->replace('"', '""')->toString(); + $schema->getConnection()->statement('UPDATE sqlite_master SET sql = "'.$modifiedTableDefinitionWithEscapedQuotes.'" WHERE type = "table" AND name = "'.$prefix.$table.'"'); + } + + $schema->getConnection()->statement('PRAGMA writable_schema = OFF'); + $schema->getConnection()->statement('PRAGMA foreign_keys = ON'); + } + }, +]; diff --git a/framework/core/migrations/install.dump b/framework/core/migrations/mysql-install.dump similarity index 100% rename from framework/core/migrations/install.dump rename to framework/core/migrations/mysql-install.dump diff --git a/framework/core/migrations/sqlite-install.dump b/framework/core/migrations/sqlite-install.dump new file mode 100644 index 0000000000..6a4cda4bc9 --- /dev/null +++ b/framework/core/migrations/sqlite-install.dump @@ -0,0 +1,132 @@ +CREATE TABLE IF NOT EXISTS "db_prefix_migrations" ("id" integer primary key autoincrement not null, "migration" varchar not null, "extension" varchar); +CREATE TABLE IF NOT EXISTS "db_prefix_groups" ("id" integer primary key autoincrement not null, "name_singular" varchar not null, "name_plural" varchar not null, "color" varchar, "icon" varchar, "is_hidden" tinyint(1) not null default '0', "created_at" datetime, "updated_at" datetime); +CREATE TABLE IF NOT EXISTS "db_prefix_group_permission" ("group_id" integer not null, "permission" varchar not null, "created_at" datetime, primary key ("group_id", "permission")); +CREATE TABLE IF NOT EXISTS "db_prefix_group_user" ("user_id" integer not null, "group_id" integer not null, "created_at" datetime, primary key ("user_id", "group_id")); +CREATE TABLE db_prefix_settings ("key" VARCHAR(255) NOT NULL, value CLOB DEFAULT NULL, PRIMARY KEY("key")); +CREATE TABLE IF NOT EXISTS "db_prefix_api_keys" ("key" varchar not null, "id" integer primary key autoincrement not null, "allowed_ips" varchar, "scopes" varchar, "user_id" integer, "created_at" datetime not null, "last_activity_at" datetime, foreign key("user_id") references "db_prefix_users"("id") on delete cascade); +CREATE UNIQUE INDEX "db_prefix_api_keys_key_unique" on "db_prefix_api_keys" ("key"); +CREATE TABLE db_prefix_discussion_user (user_id INTEGER NOT NULL, discussion_id INTEGER NOT NULL, last_read_at DATETIME DEFAULT NULL, last_read_post_number INTEGER DEFAULT NULL, PRIMARY KEY(user_id, discussion_id)); +CREATE TABLE db_prefix_email_tokens (token VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL COLLATE "BINARY", user_id INTEGER NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY(token)); +CREATE TABLE db_prefix_notifications (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_id INTEGER NOT NULL, from_user_id INTEGER DEFAULT NULL, type VARCHAR(255) NOT NULL COLLATE "BINARY", subject_id INTEGER DEFAULT NULL, data BLOB DEFAULT NULL, created_at DATETIME NOT NULL, is_deleted BOOLEAN DEFAULT 0 NOT NULL, read_at DATETIME DEFAULT NULL); +CREATE TABLE db_prefix_password_tokens (token VARCHAR(255) NOT NULL, user_id INTEGER NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY(token)); +CREATE TABLE IF NOT EXISTS "db_prefix_post_user" ("post_id" integer not null, "user_id" integer not null, foreign key("post_id") references "db_prefix_posts"("id") on delete cascade, foreign key("user_id") references "db_prefix_users"("id") on delete cascade, primary key ("post_id", "user_id")); +CREATE TABLE db_prefix_users (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, username VARCHAR(255) NOT NULL COLLATE "BINARY", email VARCHAR(255) NOT NULL COLLATE "BINARY", is_email_confirmed BOOLEAN DEFAULT 0 NOT NULL, password VARCHAR(255) NOT NULL COLLATE "BINARY", avatar_url VARCHAR(255) DEFAULT NULL, preferences BLOB DEFAULT NULL, joined_at DATETIME DEFAULT NULL, last_seen_at DATETIME DEFAULT NULL, marked_all_as_read_at DATETIME DEFAULT NULL, read_notifications_at DATETIME DEFAULT NULL, discussion_count INTEGER DEFAULT 0 NOT NULL, comment_count INTEGER DEFAULT 0 NOT NULL); +CREATE UNIQUE INDEX db_prefix_users_email_unique ON db_prefix_users (email); +CREATE UNIQUE INDEX db_prefix_users_username_unique ON db_prefix_users (username); +CREATE INDEX "db_prefix_users_joined_at_index" on "db_prefix_users" ("joined_at"); +CREATE INDEX "db_prefix_users_last_seen_at_index" on "db_prefix_users" ("last_seen_at"); +CREATE INDEX "db_prefix_users_discussion_count_index" on "db_prefix_users" ("discussion_count"); +CREATE INDEX "db_prefix_users_comment_count_index" on "db_prefix_users" ("comment_count"); +CREATE INDEX "db_prefix_notifications_user_id_index" on "db_prefix_notifications" ("user_id"); +CREATE TABLE db_prefix_registration_tokens (token VARCHAR(255) NOT NULL, payload CLOB DEFAULT NULL, created_at DATETIME NOT NULL, "provider" varchar not null, "identifier" varchar not null, "user_attributes" text, PRIMARY KEY(token)); +CREATE TABLE IF NOT EXISTS "db_prefix_login_providers" ("id" integer primary key autoincrement not null, "user_id" integer not null, "provider" varchar not null, "identifier" varchar not null, "created_at" datetime, "last_login_at" datetime, foreign key("user_id") references "db_prefix_users"("id") on delete cascade); +CREATE UNIQUE INDEX "db_prefix_login_providers_provider_identifier_unique" on "db_prefix_login_providers" ("provider", "identifier"); +CREATE TABLE IF NOT EXISTS "db_prefix_access_tokens" ("id" integer primary key autoincrement not null, "token" varchar not null, "user_id" integer not null, "last_activity_at" datetime, "created_at" datetime not null, "type" varchar, "title" varchar, "last_ip_address" varchar, "last_user_agent" varchar, foreign key("user_id") references "db_prefix_users"("id") on delete cascade); +CREATE INDEX "db_prefix_access_tokens_type_index" on "db_prefix_access_tokens" ("type"); +CREATE UNIQUE INDEX "db_prefix_access_tokens_token_unique" on "db_prefix_access_tokens" ("token"); +CREATE TABLE db_prefix_posts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, discussion_id INTEGER NOT NULL, number INTEGER DEFAULT NULL, created_at DATETIME NOT NULL, user_id INTEGER DEFAULT NULL, type VARCHAR(255) DEFAULT NULL, content CLOB DEFAULT NULL -- +, edited_at DATETIME DEFAULT NULL, edited_user_id INTEGER DEFAULT NULL, hidden_at DATETIME DEFAULT NULL, hidden_user_id INTEGER DEFAULT NULL, ip_address VARCHAR(255) DEFAULT NULL, is_private BOOLEAN DEFAULT 0 NOT NULL, foreign key("discussion_id") references "db_prefix_discussions"("id") on delete cascade, + foreign key("user_id") references "db_prefix_users"("id") on delete set null, + foreign key("edited_user_id") references "db_prefix_users"("id") on delete set null, + foreign key("hidden_user_id") references "db_prefix_users"("id") on delete set null); +CREATE INDEX db_prefix_posts_user_id_created_at_index ON db_prefix_posts (user_id, created_at); +CREATE INDEX db_prefix_posts_discussion_id_created_at_index ON db_prefix_posts (discussion_id, created_at); +CREATE INDEX db_prefix_posts_discussion_id_number_index ON db_prefix_posts (discussion_id, number); +CREATE UNIQUE INDEX db_prefix_posts_discussion_id_number_unique ON db_prefix_posts (discussion_id, number); +CREATE INDEX "db_prefix_posts_type_index" on "db_prefix_posts" ("type"); +CREATE INDEX "db_prefix_posts_type_created_at_index" on "db_prefix_posts" ("type", "created_at"); +CREATE TABLE IF NOT EXISTS "db_prefix_unsubscribe_tokens" ("id" integer primary key autoincrement not null, "user_id" integer not null, "email_type" varchar not null, "token" varchar not null, "unsubscribed_at" datetime, "created_at" datetime, "updated_at" datetime, foreign key("user_id") references "db_prefix_users"("id") on delete cascade); +CREATE INDEX "db_prefix_unsubscribe_tokens_user_id_index" on "db_prefix_unsubscribe_tokens" ("user_id"); +CREATE INDEX "db_prefix_unsubscribe_tokens_email_type_index" on "db_prefix_unsubscribe_tokens" ("email_type"); +CREATE INDEX "db_prefix_unsubscribe_tokens_token_index" on "db_prefix_unsubscribe_tokens" ("token"); +CREATE INDEX "db_prefix_unsubscribe_tokens_user_id_email_type_index" on "db_prefix_unsubscribe_tokens" ("user_id", "email_type"); +CREATE UNIQUE INDEX "db_prefix_unsubscribe_tokens_token_unique" on "db_prefix_unsubscribe_tokens" ("token"); +CREATE TABLE db_prefix_discussions (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, title VARCHAR(255) NOT NULL COLLATE "BINARY", comment_count INTEGER DEFAULT 1 NOT NULL, participant_count INTEGER DEFAULT 0 NOT NULL, created_at DATETIME NOT NULL, user_id INTEGER DEFAULT NULL, first_post_id INTEGER DEFAULT NULL, last_posted_at DATETIME DEFAULT NULL, last_posted_user_id INTEGER DEFAULT NULL, last_post_id INTEGER DEFAULT NULL, last_post_number INTEGER DEFAULT NULL, hidden_at DATETIME DEFAULT NULL, hidden_user_id INTEGER DEFAULT NULL, slug VARCHAR(255) NOT NULL COLLATE "BINARY", is_private BOOLEAN DEFAULT 0 NOT NULL, foreign key("user_id") references "db_prefix_users"("id") on delete set null, + foreign key("last_posted_user_id") references "db_prefix_users"("id") on delete set null, + foreign key("hidden_user_id") references "db_prefix_users"("id") on delete set null, + foreign key("first_post_id") references "db_prefix_posts"("id") on delete set null, + foreign key("last_post_id") references "db_prefix_posts"("id") on delete set null); +CREATE INDEX db_prefix_discussions_last_posted_at_index ON db_prefix_discussions (last_posted_at); +CREATE INDEX db_prefix_discussions_last_posted_user_id_index ON db_prefix_discussions (last_posted_user_id); +CREATE INDEX db_prefix_discussions_created_at_index ON db_prefix_discussions (created_at); +CREATE INDEX db_prefix_discussions_user_id_index ON db_prefix_discussions (user_id); +CREATE INDEX db_prefix_discussions_comment_count_index ON db_prefix_discussions (comment_count); +CREATE INDEX db_prefix_discussions_participant_count_index ON db_prefix_discussions (participant_count); +CREATE INDEX db_prefix_discussions_hidden_at_index ON db_prefix_discussions (hidden_at); +INSERT INTO db_prefix_migrations VALUES(1,'2015_02_24_000000_create_access_tokens_table',NULL); +INSERT INTO db_prefix_migrations VALUES(2,'2015_02_24_000000_create_api_keys_table',NULL); +INSERT INTO db_prefix_migrations VALUES(3,'2015_02_24_000000_create_config_table',NULL); +INSERT INTO db_prefix_migrations VALUES(4,'2015_02_24_000000_create_discussions_table',NULL); +INSERT INTO db_prefix_migrations VALUES(5,'2015_02_24_000000_create_email_tokens_table',NULL); +INSERT INTO db_prefix_migrations VALUES(6,'2015_02_24_000000_create_groups_table',NULL); +INSERT INTO db_prefix_migrations VALUES(7,'2015_02_24_000000_create_notifications_table',NULL); +INSERT INTO db_prefix_migrations VALUES(8,'2015_02_24_000000_create_password_tokens_table',NULL); +INSERT INTO db_prefix_migrations VALUES(9,'2015_02_24_000000_create_permissions_table',NULL); +INSERT INTO db_prefix_migrations VALUES(10,'2015_02_24_000000_create_posts_table',NULL); +INSERT INTO db_prefix_migrations VALUES(11,'2015_02_24_000000_create_users_discussions_table',NULL); +INSERT INTO db_prefix_migrations VALUES(12,'2015_02_24_000000_create_users_groups_table',NULL); +INSERT INTO db_prefix_migrations VALUES(13,'2015_02_24_000000_create_users_table',NULL); +INSERT INTO db_prefix_migrations VALUES(14,'2015_09_15_000000_create_auth_tokens_table',NULL); +INSERT INTO db_prefix_migrations VALUES(15,'2015_09_20_224327_add_hide_to_discussions',NULL); +INSERT INTO db_prefix_migrations VALUES(16,'2015_09_22_030432_rename_notification_read_time',NULL); +INSERT INTO db_prefix_migrations VALUES(17,'2015_10_07_130531_rename_config_to_settings',NULL); +INSERT INTO db_prefix_migrations VALUES(18,'2015_10_24_194000_add_ip_address_to_posts',NULL); +INSERT INTO db_prefix_migrations VALUES(19,'2015_12_05_042721_change_access_tokens_columns',NULL); +INSERT INTO db_prefix_migrations VALUES(20,'2015_12_17_194247_change_settings_value_column_to_text',NULL); +INSERT INTO db_prefix_migrations VALUES(21,'2016_02_04_095452_add_slug_to_discussions',NULL); +INSERT INTO db_prefix_migrations VALUES(22,'2017_04_07_114138_add_is_private_to_discussions',NULL); +INSERT INTO db_prefix_migrations VALUES(23,'2017_04_07_114138_add_is_private_to_posts',NULL); +INSERT INTO db_prefix_migrations VALUES(24,'2018_01_11_093900_change_access_tokens_columns',NULL); +INSERT INTO db_prefix_migrations VALUES(25,'2018_01_11_094000_change_access_tokens_add_foreign_keys',NULL); +INSERT INTO db_prefix_migrations VALUES(26,'2018_01_11_095000_change_api_keys_columns',NULL); +INSERT INTO db_prefix_migrations VALUES(27,'2018_01_11_101800_rename_auth_tokens_to_registration_tokens',NULL); +INSERT INTO db_prefix_migrations VALUES(28,'2018_01_11_102000_change_registration_tokens_rename_id_to_token',NULL); +INSERT INTO db_prefix_migrations VALUES(29,'2018_01_11_102100_change_registration_tokens_created_at_to_datetime',NULL); +INSERT INTO db_prefix_migrations VALUES(30,'2018_01_11_120604_change_posts_table_to_innodb',NULL); +INSERT INTO db_prefix_migrations VALUES(31,'2018_01_11_155200_change_discussions_rename_columns',NULL); +INSERT INTO db_prefix_migrations VALUES(32,'2018_01_11_155300_change_discussions_add_foreign_keys',NULL); +INSERT INTO db_prefix_migrations VALUES(33,'2018_01_15_071700_rename_users_discussions_to_discussion_user',NULL); +INSERT INTO db_prefix_migrations VALUES(34,'2018_01_15_071800_change_discussion_user_rename_columns',NULL); +INSERT INTO db_prefix_migrations VALUES(35,'2018_01_15_071900_change_discussion_user_add_foreign_keys',NULL); +INSERT INTO db_prefix_migrations VALUES(36,'2018_01_15_072600_change_email_tokens_rename_id_to_token',NULL); +INSERT INTO db_prefix_migrations VALUES(37,'2018_01_15_072700_change_email_tokens_add_foreign_keys',NULL); +INSERT INTO db_prefix_migrations VALUES(38,'2018_01_15_072800_change_email_tokens_created_at_to_datetime',NULL); +INSERT INTO db_prefix_migrations VALUES(39,'2018_01_18_130400_rename_permissions_to_group_permission',NULL); +INSERT INTO db_prefix_migrations VALUES(40,'2018_01_18_130500_change_group_permission_add_foreign_keys',NULL); +INSERT INTO db_prefix_migrations VALUES(41,'2018_01_18_130600_rename_users_groups_to_group_user',NULL); +INSERT INTO db_prefix_migrations VALUES(42,'2018_01_18_130700_change_group_user_add_foreign_keys',NULL); +INSERT INTO db_prefix_migrations VALUES(43,'2018_01_18_133000_change_notifications_columns',NULL); +INSERT INTO db_prefix_migrations VALUES(44,'2018_01_18_133100_change_notifications_add_foreign_keys',NULL); +INSERT INTO db_prefix_migrations VALUES(45,'2018_01_18_134400_change_password_tokens_rename_id_to_token',NULL); +INSERT INTO db_prefix_migrations VALUES(46,'2018_01_18_134500_change_password_tokens_add_foreign_keys',NULL); +INSERT INTO db_prefix_migrations VALUES(47,'2018_01_18_134600_change_password_tokens_created_at_to_datetime',NULL); +INSERT INTO db_prefix_migrations VALUES(48,'2018_01_18_135000_change_posts_rename_columns',NULL); +INSERT INTO db_prefix_migrations VALUES(49,'2018_01_18_135100_change_posts_add_foreign_keys',NULL); +INSERT INTO db_prefix_migrations VALUES(50,'2018_01_30_112238_add_fulltext_index_to_discussions_title',NULL); +INSERT INTO db_prefix_migrations VALUES(51,'2018_01_30_220100_create_post_user_table',NULL); +INSERT INTO db_prefix_migrations VALUES(52,'2018_01_30_222900_change_users_rename_columns',NULL); +INSERT INTO db_prefix_migrations VALUES(55,'2018_09_15_041340_add_users_indicies',NULL); +INSERT INTO db_prefix_migrations VALUES(56,'2018_09_15_041828_add_discussions_indicies',NULL); +INSERT INTO db_prefix_migrations VALUES(57,'2018_09_15_043337_add_notifications_indices',NULL); +INSERT INTO db_prefix_migrations VALUES(58,'2018_09_15_043621_add_posts_indices',NULL); +INSERT INTO db_prefix_migrations VALUES(59,'2018_09_22_004100_change_registration_tokens_columns',NULL); +INSERT INTO db_prefix_migrations VALUES(60,'2018_09_22_004200_create_login_providers_table',NULL); +INSERT INTO db_prefix_migrations VALUES(61,'2018_10_08_144700_add_shim_prefix_to_group_icons',NULL); +INSERT INTO db_prefix_migrations VALUES(62,'2019_10_12_195349_change_posts_add_discussion_foreign_key',NULL); +INSERT INTO db_prefix_migrations VALUES(63,'2020_03_19_134512_change_discussions_default_comment_count',NULL); +INSERT INTO db_prefix_migrations VALUES(64,'2020_04_21_130500_change_permission_groups_add_is_hidden',NULL); +INSERT INTO db_prefix_migrations VALUES(65,'2021_03_02_040000_change_access_tokens_add_type',NULL); +INSERT INTO db_prefix_migrations VALUES(66,'2021_03_02_040500_change_access_tokens_add_id',NULL); +INSERT INTO db_prefix_migrations VALUES(67,'2021_03_02_041000_change_access_tokens_add_title_ip_agent',NULL); +INSERT INTO db_prefix_migrations VALUES(68,'2021_04_18_040500_change_migrations_add_id_primary_key',NULL); +INSERT INTO db_prefix_migrations VALUES(69,'2021_04_18_145100_change_posts_content_column_to_mediumtext',NULL); +INSERT INTO db_prefix_migrations VALUES(70,'2021_05_10_000000_rename_permissions',NULL); +INSERT INTO db_prefix_migrations VALUES(71,'2022_05_20_000000_add_timestamps_to_groups_table',NULL); +INSERT INTO db_prefix_migrations VALUES(72,'2022_05_20_000001_add_created_at_to_group_user_table',NULL); +INSERT INTO db_prefix_migrations VALUES(73,'2022_05_20_000002_add_created_at_to_group_permission_table',NULL); +INSERT INTO db_prefix_migrations VALUES(74,'2022_07_14_000000_add_type_index_to_posts',NULL); +INSERT INTO db_prefix_migrations VALUES(75,'2022_07_14_000001_add_type_created_at_composite_index_to_posts',NULL); +INSERT INTO db_prefix_migrations VALUES(76,'2022_08_06_000000_change_access_tokens_last_activity_at_to_nullable',NULL); +INSERT INTO db_prefix_migrations VALUES(77,'2023_08_19_000000_create_unsubscribe_tokens_table',NULL); +INSERT INTO db_prefix_migrations VALUES(78,'2023_10_23_000000_drop_post_number_index_column_from_discussions_table',NULL); +INSERT INTO db_prefix_migrations VALUES(79,'2024_05_05_000000_add_sqlite_keys',NULL); diff --git a/framework/core/src/Admin/Content/AdminPayload.php b/framework/core/src/Admin/Content/AdminPayload.php index 557115389e..b7d9806a53 100644 --- a/framework/core/src/Admin/Content/AdminPayload.php +++ b/framework/core/src/Admin/Content/AdminPayload.php @@ -60,7 +60,8 @@ public function __invoke(Document $document, Request $request): void $document->payload['advancedPageEmpty'] = $this->checkAdvancedPageEmpty(); $document->payload['phpVersion'] = $this->appInfo->identifyPHPVersion(); - $document->payload['mysqlVersion'] = $this->appInfo->identifyDatabaseVersion(); + $document->payload['dbDriver'] = $this->appInfo->identifyDatabaseDriver(); + $document->payload['dbVersion'] = $this->appInfo->identifyDatabaseVersion(); $document->payload['debugEnabled'] = Arr::get($this->config, 'debug'); if ($this->appInfo->scheduledTasksRegistered()) { diff --git a/framework/core/src/Admin/Content/Index.php b/framework/core/src/Admin/Content/Index.php index b70466fb59..a47d7a5151 100644 --- a/framework/core/src/Admin/Content/Index.php +++ b/framework/core/src/Admin/Content/Index.php @@ -31,13 +31,14 @@ public function __invoke(Document $document, Request $request): Document $extensionsEnabled = json_decode($this->settings->get('extensions_enabled', '{}'), true); $csrfToken = $request->getAttribute('session')->token(); - $mysqlVersion = $document->payload['mysqlVersion']; + $dbDriver = $document->payload['dbDriver']; + $dbVersion = $document->payload['dbVersion']; $phpVersion = $document->payload['phpVersion']; $flarumVersion = Application::VERSION; $document->content = $this->view->make( 'flarum.admin::frontend.content.admin', - compact('extensions', 'extensionsEnabled', 'csrfToken', 'flarumVersion', 'phpVersion', 'mysqlVersion') + compact('extensions', 'extensionsEnabled', 'csrfToken', 'flarumVersion', 'phpVersion', 'dbVersion', 'dbDriver') ); return $document; diff --git a/framework/core/src/Database/AbstractModel.php b/framework/core/src/Database/AbstractModel.php index 812952fbc9..65c15a7ddf 100644 --- a/framework/core/src/Database/AbstractModel.php +++ b/framework/core/src/Database/AbstractModel.php @@ -68,6 +68,13 @@ abstract class AbstractModel extends Eloquent */ protected ?string $tableAlias = null; + /** + * If a model has unique keys, they should be defined here. + * + * @var array|null + */ + public ?array $uniqueKeys = null; + public static function boot() { parent::boot(); diff --git a/framework/core/src/Database/Console/GenerateDumpCommand.php b/framework/core/src/Database/Console/GenerateDumpCommand.php index 559f41ab2a..2e0838b902 100644 --- a/framework/core/src/Database/Console/GenerateDumpCommand.php +++ b/framework/core/src/Database/Console/GenerateDumpCommand.php @@ -10,6 +10,7 @@ namespace Flarum\Database\Console; use Flarum\Console\AbstractCommand; +use Flarum\Foundation\Config; use Flarum\Foundation\Paths; use Illuminate\Database\Connection; use Illuminate\Database\MySqlConnection; @@ -19,6 +20,7 @@ class GenerateDumpCommand extends AbstractCommand { public function __construct( protected Connection $connection, + protected Config $config, protected Paths $paths ) { parent::__construct(); @@ -33,7 +35,8 @@ protected function configure(): void protected function fire(): int { - $dumpPath = __DIR__.'/../../../migrations/install.dump'; + $driver = $this->config['database.driver']; + $dumpPath = __DIR__."/../../../migrations/$driver-install.dump"; /** @var Connection&MySqlConnection $connection */ $connection = resolve('db.connection'); diff --git a/framework/core/src/Database/DatabaseMigrationRepository.php b/framework/core/src/Database/DatabaseMigrationRepository.php index 9bd1fba42d..3fb06fba3d 100644 --- a/framework/core/src/Database/DatabaseMigrationRepository.php +++ b/framework/core/src/Database/DatabaseMigrationRepository.php @@ -49,6 +49,26 @@ public function delete(string $file, ?string $extension = null): void $query->delete(); } + /** + * Create the migration repository data store. + * + * @return void + */ + public function createRepository(): void + { + if ($this->repositoryExists()) { + return; + } + + $schema = $this->connection->getSchemaBuilder(); + + $schema->create($this->table, function ($table) { + $table->increments('id'); + $table->string('migration'); + $table->string('extension')->nullable(); + }); + } + public function repositoryExists(): bool { $schema = $this->connection->getSchemaBuilder(); diff --git a/framework/core/src/Database/DatabaseServiceProvider.php b/framework/core/src/Database/DatabaseServiceProvider.php index 99fef1674e..cb05162160 100644 --- a/framework/core/src/Database/DatabaseServiceProvider.php +++ b/framework/core/src/Database/DatabaseServiceProvider.php @@ -12,6 +12,7 @@ use Faker\Factory as FakerFactory; use Faker\Generator as FakerGenerator; use Flarum\Foundation\AbstractServiceProvider; +use Flarum\Foundation\Paths; use Illuminate\Container\Container as ContainerImplementation; use Illuminate\Contracts\Container\Container; use Illuminate\Database\Capsule\Manager; @@ -32,7 +33,13 @@ public function register(): void $manager = new Manager($container); $config = $container['flarum']->config('database'); - $config['engine'] = 'InnoDB'; + + if ($config['driver'] === 'mysql') { + $config['engine'] = 'InnoDB'; + } elseif ($config['driver'] === 'sqlite' && ! file_exists($config['database'])) { + $config['database'] = $container->make(Paths::class)->base.'/'.$config['database']; + } + $config['prefix_indexes'] = true; $manager->addConnection($config, 'flarum'); diff --git a/framework/core/src/Database/Migration.php b/framework/core/src/Database/Migration.php index 3ef10cbfbb..a8b7c70556 100644 --- a/framework/core/src/Database/Migration.php +++ b/framework/core/src/Database/Migration.php @@ -101,18 +101,18 @@ public static function renameColumns(string $tableName, array $columnNames): arr { return [ 'up' => function (Builder $schema) use ($tableName, $columnNames) { - $schema->table($tableName, function (Blueprint $table) use ($columnNames) { - foreach ($columnNames as $from => $to) { + foreach ($columnNames as $from => $to) { + $schema->table($tableName, function (Blueprint $table) use ($from, $to) { $table->renameColumn($from, $to); - } - }); + }); + } }, 'down' => function (Builder $schema) use ($tableName, $columnNames) { - $schema->table($tableName, function (Blueprint $table) use ($columnNames) { - foreach ($columnNames as $to => $from) { + foreach ($columnNames as $to => $from) { + $schema->table($tableName, function (Blueprint $table) use ($from, $to) { $table->renameColumn($from, $to); - } - }); + }); + } } ]; } diff --git a/framework/core/src/Database/Migrator.php b/framework/core/src/Database/Migrator.php index 15432ef0ce..a132a80370 100644 --- a/framework/core/src/Database/Migrator.php +++ b/framework/core/src/Database/Migrator.php @@ -9,13 +9,13 @@ namespace Flarum\Database; +use Doctrine\DBAL\Types\Type; use Flarum\Database\Exception\MigrationKeyMissing; use Flarum\Extension\Extension; use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Database\ConnectionInterface; -use Illuminate\Database\MySqlConnection; +use Illuminate\Database\DBAL\TimestampType; use Illuminate\Filesystem\Filesystem; -use InvalidArgumentException; use RuntimeException; use Symfony\Component\Console\Output\OutputInterface; @@ -31,12 +31,14 @@ public function __construct( protected ConnectionInterface $connection, protected Filesystem $files ) { - if (! ($connection instanceof MySqlConnection)) { - throw new InvalidArgumentException('Only MySQL connections are supported'); + $doctrine = $connection->getDoctrineConnection()->getDatabasePlatform(); + + if (! Type::hasType('timestamp')) { + Type::addType('timestamp', TimestampType::class); } // Workaround for https://github.com/laravel/framework/issues/1186 - $connection->getDoctrineSchemaManager()->getDatabasePlatform()->registerDoctrineTypeMapping('enum', 'string'); + $doctrine->registerDoctrineTypeMapping('enum', 'string'); } /** @@ -207,9 +209,13 @@ public function resolve(string $path, string $file): array * * @param string $path to the directory containing the dump. */ - public function installFromSchema(string $path): void + public function installFromSchema(string $path, string $driver): bool { - $schemaPath = "$path/install.dump"; + $schemaPath = "$path/$driver-install.dump"; + + if (! file_exists($schemaPath)) { + return false; + } $startTime = microtime(true); @@ -236,6 +242,8 @@ public function installFromSchema(string $path): void $runTime = number_format((microtime(true) - $startTime) * 1000, 2); $this->note('Loaded stored database schema. ('.$runTime.'ms)'); + + return true; } public function setOutput(OutputInterface $output): static @@ -250,6 +258,16 @@ protected function note(string $message): void $this->output?->writeln($message); } + /** + * Get the migration repository instance. + * + * @return MigrationRepositoryInterface + */ + public function getRepository() + { + return $this->repository; + } + public function repositoryExists(): bool { return $this->repository->repositoryExists(); diff --git a/framework/core/src/Discussion/Discussion.php b/framework/core/src/Discussion/Discussion.php index 05a38ae654..924f9d6346 100644 --- a/framework/core/src/Discussion/Discussion.php +++ b/framework/core/src/Discussion/Discussion.php @@ -109,6 +109,9 @@ public static function boot() $discussion->raise(new Deleted($discussion)); Notification::whereSubject($discussion)->delete(); + + // SQLite foreign constraints don't work since they were added *after* the table creation. + $discussion->posts()->delete(); }); } diff --git a/framework/core/src/Discussion/Search/FulltextFilter.php b/framework/core/src/Discussion/Search/FulltextFilter.php index da0b62a039..ada06a184b 100644 --- a/framework/core/src/Discussion/Search/FulltextFilter.php +++ b/framework/core/src/Discussion/Search/FulltextFilter.php @@ -24,12 +24,33 @@ class FulltextFilter extends AbstractFulltextFilter { public function search(SearchState $state, string $value): void { + /** @var Builder $query */ + $query = $state->getQuery(); + + if ($query->getConnection()->getDriverName() === 'sqlite') { + $query->where(function (Builder $query) use ($state, $value) { + $query->where('discussions.title', 'like', "%$value%") + ->orWhereExists(function (Builder $query) use ($state, $value) { + $query->selectRaw('1') + ->from( + Post::whereVisibleTo($state->getActor()) + ->whereColumn('discussion_id', 'discussions.id') + ->where('type', 'comment') + ->where('content', 'like', "%$value%") + ->limit(1) + ->toBase() + ); + }); + }); + + return; + } + // Replace all non-word characters with spaces. // We do this to prevent MySQL fulltext search boolean mode from taking // effect: https://dev.mysql.com/doc/refman/5.7/en/fulltext-boolean.html $value = preg_replace('/[^\p{L}\p{N}\p{M}_]+/u', ' ', $value); - $query = $state->getQuery(); $grammar = $query->getGrammar(); $discussionSubquery = Discussion::select('id') diff --git a/framework/core/src/Foundation/ApplicationInfoProvider.php b/framework/core/src/Foundation/ApplicationInfoProvider.php index b881906167..0f5aa22112 100644 --- a/framework/core/src/Foundation/ApplicationInfoProvider.php +++ b/framework/core/src/Foundation/ApplicationInfoProvider.php @@ -70,7 +70,20 @@ public function identifyQueueDriver(): string public function identifyDatabaseVersion(): string { - return $this->db->selectOne('select version() as version')->version; + return match ($this->config['database.driver']) { + 'mysql' => $this->db->selectOne('select version() as version')->version, + 'sqlite' => $this->db->selectOne('select sqlite_version() as version')->version, + default => 'Unknown', + }; + } + + public function identifyDatabaseDriver(): string + { + return match ($this->config['database.driver']) { + 'mysql' => 'MySQL', + 'sqlite' => 'SQLite', + default => $this->config['database.driver'], + }; } /** diff --git a/framework/core/src/Foundation/Console/InfoCommand.php b/framework/core/src/Foundation/Console/InfoCommand.php index 4256b79fe3..d3967e1521 100644 --- a/framework/core/src/Foundation/Console/InfoCommand.php +++ b/framework/core/src/Foundation/Console/InfoCommand.php @@ -45,7 +45,7 @@ protected function fire(): int $this->output->writeln("Flarum core: $coreVersion"); $this->output->writeln('PHP version: '.$this->appInfo->identifyPHPVersion()); - $this->output->writeln('MySQL version: '.$this->appInfo->identifyDatabaseVersion()); + $this->output->writeln(''.$this->appInfo->identifyDatabaseDriver().' version: '.$this->appInfo->identifyDatabaseVersion()); $phpExtensions = implode(', ', get_loaded_extensions()); $this->output->writeln("Loaded extensions: $phpExtensions"); diff --git a/framework/core/src/Install/Console/UserDataProvider.php b/framework/core/src/Install/Console/UserDataProvider.php index f72b55bb72..5ef3de9901 100644 --- a/framework/core/src/Install/Console/UserDataProvider.php +++ b/framework/core/src/Install/Console/UserDataProvider.php @@ -50,7 +50,7 @@ private function getDatabaseConfiguration(): DatabaseConfig } return new DatabaseConfig( - 'mysql', + $this->ask('Database driver (mysql, sqlite) (Default: mysql):', 'mysql'), $host, intval($port), $this->ask('Database name (required):'), diff --git a/framework/core/src/Install/Controller/InstallController.php b/framework/core/src/Install/Controller/InstallController.php index 0d98540a45..f066c794c1 100644 --- a/framework/core/src/Install/Controller/InstallController.php +++ b/framework/core/src/Install/Controller/InstallController.php @@ -84,10 +84,10 @@ private function makeDatabaseConfig(array $input): DatabaseConfig } return new DatabaseConfig( - 'mysql', + Arr::get($input, 'dbDriver'), $host, intval($port), - Arr::get($input, 'mysqlDatabase'), + Arr::get($input, 'dbName'), Arr::get($input, 'mysqlUsername'), Arr::get($input, 'mysqlPassword'), Arr::get($input, 'tablePrefix') diff --git a/framework/core/src/Install/DatabaseConfig.php b/framework/core/src/Install/DatabaseConfig.php index b0d46134c1..b5e4c9ed78 100644 --- a/framework/core/src/Install/DatabaseConfig.php +++ b/framework/core/src/Install/DatabaseConfig.php @@ -9,6 +9,7 @@ namespace Flarum\Install; +use Flarum\Foundation\Paths; use Illuminate\Contracts\Support\Arrayable; class DatabaseConfig implements Arrayable @@ -17,7 +18,7 @@ public function __construct( private readonly string $driver, private readonly string $host, private readonly int $port, - private readonly string $database, + private string $database, private readonly string $username, private readonly string $password, private readonly string $prefix @@ -27,20 +28,12 @@ public function __construct( public function toArray(): array { - return [ + return array_merge([ 'driver' => $this->driver, - 'host' => $this->host, - 'port' => $this->port, 'database' => $this->database, - 'username' => $this->username, - 'password' => $this->password, - 'charset' => 'utf8mb4', - 'collation' => 'utf8mb4_unicode_ci', 'prefix' => $this->prefix, - 'strict' => false, - 'engine' => 'InnoDB', 'prefix_indexes' => true - ]; + ], $this->driverOptions()); } private function validate(): void @@ -49,15 +42,15 @@ private function validate(): void throw new ValidationFailed('Please specify a database driver.'); } - if ($this->driver !== 'mysql') { - throw new ValidationFailed('Currently, only MySQL/MariaDB is supported.'); + if (! in_array($this->driver, ['mysql', 'sqlite'])) { + throw new ValidationFailed('Currently, only MySQL/MariaDB and SQLite are supported.'); } - if (empty($this->host)) { + if ($this->driver === 'mysql' && empty($this->host)) { throw new ValidationFailed('Please specify the hostname of your database server.'); } - if ($this->port < 1 || $this->port > 65535) { + if ($this->driver === 'mysql' && ($this->port < 1 || $this->port > 65535)) { throw new ValidationFailed('Please provide a valid port number between 1 and 65535.'); } @@ -65,7 +58,7 @@ private function validate(): void throw new ValidationFailed('Please specify the database name.'); } - if (empty($this->username)) { + if ($this->driver === 'mysql' && empty($this->username)) { throw new ValidationFailed('Please specify the username for accessing the database.'); } @@ -79,4 +72,32 @@ private function validate(): void } } } + + public function prepare(Paths $paths): void + { + if ($this->driver === 'sqlite' && ! file_exists($this->database)) { + $this->database = str_replace('.sqlite', '', $this->database).'.sqlite'; + touch($paths->base.'/'.$this->database); + } + } + + private function driverOptions(): array + { + return match ($this->driver) { + 'mysql' => [ + 'host' => $this->host, + 'port' => $this->port, + 'username' => $this->username, + 'password' => $this->password, + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'engine' => 'InnoDB', + 'strict' => false, + ], + 'sqlite' => [ + 'foreign_key_constraints' => true, + ], + default => [] + }; + } } diff --git a/framework/core/src/Install/Installation.php b/framework/core/src/Install/Installation.php index 28017d5046..68926ee04e 100644 --- a/framework/core/src/Install/Installation.php +++ b/framework/core/src/Install/Installation.php @@ -116,12 +116,15 @@ public function build(): Pipeline { $pipeline = new Pipeline; + $this->dbConfig->prepare($this->paths); + $pipeline->pipe(function () { return new Steps\ConnectToDatabase( $this->dbConfig, function ($connection) { $this->db = $connection; - } + }, + $this->paths->base ); }); @@ -135,7 +138,7 @@ function ($connection) { }); $pipeline->pipe(function () { - return new Steps\RunMigrations($this->db, $this->getMigrationPath()); + return new Steps\RunMigrations($this->db, $this->dbConfig->toArray()['driver'], $this->getMigrationPath()); }); $pipeline->pipe(function () { diff --git a/framework/core/src/Install/Steps/ConnectToDatabase.php b/framework/core/src/Install/Steps/ConnectToDatabase.php index d06907022a..a978c12b11 100644 --- a/framework/core/src/Install/Steps/ConnectToDatabase.php +++ b/framework/core/src/Install/Steps/ConnectToDatabase.php @@ -13,15 +13,19 @@ use Flarum\Install\DatabaseConfig; use Flarum\Install\Step; use Illuminate\Database\Connectors\MySqlConnector; +use Illuminate\Database\Connectors\SQLiteConnector; use Illuminate\Database\MySqlConnection; +use Illuminate\Database\SQLiteConnection; use Illuminate\Support\Str; +use InvalidArgumentException; use RangeException; class ConnectToDatabase implements Step { public function __construct( private readonly DatabaseConfig $dbConfig, - private readonly Closure $store + private readonly Closure $store, + private readonly string $basePath ) { } @@ -33,17 +37,27 @@ public function getMessage(): string public function run(): void { $config = $this->dbConfig->toArray(); + + match ($config['driver']) { + 'mysql' => $this->mysql($config), + 'sqlite' => $this->sqlite($config), + default => throw new InvalidArgumentException('Unsupported database driver: '.$config['driver']), + }; + } + + private function mysql(array $config): void + { $pdo = (new MySqlConnector)->connect($config); $version = $pdo->query('SELECT VERSION()')->fetchColumn(); if (Str::contains($version, 'MariaDB')) { - if (version_compare($version, '10.0.5', '<')) { + if (version_compare($version, '10.10.0', '<')) { throw new RangeException('MariaDB version too low. You need at least MariaDB 10.0.5'); } } else { - if (version_compare($version, '5.6.0', '<')) { - throw new RangeException('MySQL version too low. You need at least MySQL 5.6.'); + if (version_compare($version, '5.7.0', '<')) { + throw new RangeException('MySQL version too low. You need at least MySQL 5.7'); } } @@ -56,4 +70,28 @@ public function run(): void ) ); } + + private function sqlite(array $config): void + { + if (! file_exists($config['database'])) { + $config['database'] = $this->basePath.'/'.$config['database']; + } + + $pdo = (new SQLiteConnector())->connect($config); + + $version = $pdo->query('SELECT sqlite_version()')->fetchColumn(); + + if (version_compare($version, '3.8.8', '<')) { + throw new RangeException('SQLite version too low. You need at least SQLite 3.8.8'); + } + + ($this->store)( + new SQLiteConnection( + $pdo, + $config['database'], + $config['prefix'], + $config + ) + ); + } } diff --git a/framework/core/src/Install/Steps/RunMigrations.php b/framework/core/src/Install/Steps/RunMigrations.php index 19d25c25cd..254bbf1f54 100644 --- a/framework/core/src/Install/Steps/RunMigrations.php +++ b/framework/core/src/Install/Steps/RunMigrations.php @@ -19,6 +19,7 @@ class RunMigrations implements Step { public function __construct( private readonly ConnectionInterface $database, + private readonly string $driver, private readonly string $path ) { } @@ -32,7 +33,10 @@ public function run(): void { $migrator = $this->getMigrator(); - $migrator->installFromSchema($this->path); + if (! $migrator->installFromSchema($this->path, $this->driver)) { + $migrator->getRepository()->createRepository(); + } + $migrator->run($this->path); } diff --git a/framework/core/src/Post/Post.php b/framework/core/src/Post/Post.php index df52c78fc8..cf301568eb 100644 --- a/framework/core/src/Post/Post.php +++ b/framework/core/src/Post/Post.php @@ -95,8 +95,7 @@ public static function boot() $post->number = new Expression('('. $db->table('posts', 'pn') ->whereRaw($db->getTablePrefix().'pn.discussion_id = '.intval($post->discussion_id)) - // IFNULL only works on MySQL/MariaDB - ->selectRaw('IFNULL(MAX('.$db->getTablePrefix().'pn.number), 0) + 1') + ->selectRaw('COALESCE(MAX('.$db->getTablePrefix().'pn.number), 0) + 1') ->toSql() .')'); }); diff --git a/framework/core/src/User/Search/Filter/GroupFilter.php b/framework/core/src/User/Search/Filter/GroupFilter.php index b1de2035c9..aee788411e 100644 --- a/framework/core/src/User/Search/Filter/GroupFilter.php +++ b/framework/core/src/User/Search/Filter/GroupFilter.php @@ -37,7 +37,6 @@ public function filter(SearchState $state, string|array $value, bool $negate): v protected function constrain(Builder $query, User $actor, string|array $rawQuery, bool $negate): void { $groupIdentifiers = $this->asStringArray($rawQuery); - $groupQuery = Group::whereVisibleTo($actor); $ids = []; $names = []; @@ -49,11 +48,15 @@ protected function constrain(Builder $query, User $actor, string|array $rawQuery } } - $groupQuery->whereIn('groups.id', $ids) - ->orWhereIn('name_singular', $names) - ->orWhereIn('name_plural', $names); + $groupQuery = Group::whereVisibleTo($actor) + ->join('group_user', 'groups.id', 'group_user.group_id') + ->where(function (\Illuminate\Database\Eloquent\Builder $query) use ($ids, $names) { + $query->whereIn('groups.id', $ids) + ->orWhereIn($query->raw('lower(name_singular)'), $names) + ->orWhereIn($query->raw('lower(name_plural)'), $names); + }); - $userIds = $groupQuery->join('group_user', 'groups.id', 'group_user.group_id') + $userIds = $groupQuery ->pluck('group_user.user_id') ->all(); diff --git a/framework/core/tests/integration/api/discussions/ListWithFulltextSearchTest.php b/framework/core/tests/integration/api/discussions/ListWithFulltextSearchTest.php index ae598cb789..395bf55ec5 100644 --- a/framework/core/tests/integration/api/discussions/ListWithFulltextSearchTest.php +++ b/framework/core/tests/integration/api/discussions/ListWithFulltextSearchTest.php @@ -73,6 +73,10 @@ protected function tearDown(): void */ public function can_search_for_word_or_title_in_post() { + if ($this->database()->getDriverName() === 'sqlite') { + return $this->markTestSkipped('No fulltext search in SQLite.'); + } + $response = $this->send( $this->request('GET', '/api/discussions') ->withQueryParams([ @@ -94,6 +98,10 @@ public function can_search_for_word_or_title_in_post() */ public function ignores_non_word_characters_when_searching() { + if ($this->database()->getDriverName() === 'sqlite') { + return $this->markTestSkipped('No fulltext search in SQLite.'); + } + $response = $this->send( $this->request('GET', '/api/discussions') ->withQueryParams([ @@ -115,6 +123,10 @@ public function ignores_non_word_characters_when_searching() */ public function can_search_telugu_like_languages() { + if ($this->database()->getDriverName() === 'sqlite') { + return $this->markTestSkipped('No fulltext search in SQLite.'); + } + $response = $this->send( $this->request('GET', '/api/discussions') ->withQueryParams([ @@ -137,6 +149,10 @@ public function can_search_telugu_like_languages() */ public function can_search_cjk_languages() { + if ($this->database()->getDriverName() === 'sqlite') { + return $this->markTestSkipped('No fulltext search in SQLite.'); + } + $response = $this->send( $this->request('GET', '/api/discussions') ->withQueryParams([ @@ -159,6 +175,10 @@ public function can_search_cjk_languages() */ public function search_for_special_characters_gives_empty_result() { + if ($this->database()->getDriverName() === 'sqlite') { + return $this->markTestSkipped('No fulltext search in SQLite.'); + } + $response = $this->send( $this->request('GET', '/api/discussions') ->withQueryParams([ diff --git a/framework/core/views/frontend/content/admin.blade.php b/framework/core/views/frontend/content/admin.blade.php index 6f5b6ae55a..44f98da1a3 100644 --- a/framework/core/views/frontend/content/admin.blade.php +++ b/framework/core/views/frontend/content/admin.blade.php @@ -15,8 +15,8 @@ {{ $phpVersion }} - MySQL - {{ $mysqlVersion }} + {{ $dbDriver }} + {{ $dbVersion }} diff --git a/framework/core/views/install/app.php b/framework/core/views/install/app.php index 3e4f572de5..1e312f4d98 100644 --- a/framework/core/views/install/app.php +++ b/framework/core/views/install/app.php @@ -15,7 +15,7 @@ padding: 0; line-height: 1.5; } - body, input, button { + body, .FormControl, button { font-family: 'Open Sans', sans-serif; font-size: 16px; color: #7E96B3; @@ -50,15 +50,19 @@ .FormGroup { margin-bottom: 20px; } - .FormGroup .FormField:first-child input { + .FormGroup .FormField:first-child .FormControl { border-top-left-radius: 4px; border-top-right-radius: 4px; } - .FormGroup .FormField:last-child input { + .FormGroup .FormField:last-child .FormControl { border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; } - .FormField input { + .FormField select.FormControl { + -webkit-appearance: none; + -moz-appearance: none; + } + .FormField .FormControl { background: #EDF2F7; margin: 0 0 1px; border: 2px solid transparent; @@ -67,7 +71,7 @@ padding: 15px 15px 15px 180px; box-sizing: border-box; } - .FormField input:focus { + .FormField .FormControl:focus { border-color: #e7652e; background: #fff; color: #444; @@ -106,6 +110,17 @@ margin-bottom: 20px; } + .Alert { + padding: 15px 20px; + border-radius: 4px; + margin-bottom: 20px; + } + + .Alert--warning { + background: #fff2ae; + color: #ad6c00; + } + .animated { -webkit-animation-fill-mode: both; animation-fill-mode: both; diff --git a/framework/core/views/install/install.php b/framework/core/views/install/install.php index f28fea675e..37bb260f21 100644 --- a/framework/core/views/install/install.php +++ b/framework/core/views/install/install.php @@ -8,56 +8,74 @@
- +
-
- - +
+
+ Warning: Please keep in mind that while Flarum supports SQLite, not all ecosystem extensions do. If you're planning to install extensions, you should expect some of them to not work properly or at all. +
+
+
- - + +
- - + +
-
- - +
+
+ + +
+ +
+ + +
+ +
+ + +
- +
- +
- +
- +
- +
@@ -70,6 +88,18 @@ document.addEventListener('DOMContentLoaded', function() { document.querySelector('form input').select(); + document.querySelector('select[name="dbDriver"]').addEventListener('change', function() { + document.querySelectorAll('[data-group]').forEach(function(group) { + group.style.display = 'none'; + }); + + const groups = document.querySelectorAll('[data-group="' + this.value + '"]'); + + groups.forEach(function(group) { + group.style.display = 'block'; + }); + }); + document.querySelector('form').addEventListener('submit', function(e) { e.preventDefault(); diff --git a/framework/core/views/install/update.php b/framework/core/views/install/update.php index 1cbbc1639f..bd8a05e7ef 100644 --- a/framework/core/views/install/update.php +++ b/framework/core/views/install/update.php @@ -8,7 +8,7 @@
- +
diff --git a/php-packages/testing/src/integration/Extend/BeginTransactionAndSetDatabase.php b/php-packages/testing/src/integration/Extend/BeginTransactionAndSetDatabase.php index 565164bdbb..fe919da306 100644 --- a/php-packages/testing/src/integration/Extend/BeginTransactionAndSetDatabase.php +++ b/php-packages/testing/src/integration/Extend/BeginTransactionAndSetDatabase.php @@ -12,6 +12,7 @@ use Flarum\Extend\ExtenderInterface; use Flarum\Extension\Extension; use Illuminate\Contracts\Container\Container; +use Illuminate\Database\Connection; use Illuminate\Database\ConnectionInterface; class BeginTransactionAndSetDatabase implements ExtenderInterface @@ -28,8 +29,14 @@ public function __construct(callable $setDbOnTestCase) public function extend(Container $container, Extension $extension = null): void { + /** @var Connection $db */ $db = $container->make(ConnectionInterface::class); + // SQLite requires this be done outside a transaction. + if ($db->getDriverName() === 'sqlite') { + $db->getSchemaBuilder()->disableForeignKeyConstraints(); + } + $db->beginTransaction(); ($this->setDbOnTestCase)($db); diff --git a/php-packages/testing/src/integration/Setup/SetupScript.php b/php-packages/testing/src/integration/Setup/SetupScript.php index e6d70a9eaf..5b190cf944 100644 --- a/php-packages/testing/src/integration/Setup/SetupScript.php +++ b/php-packages/testing/src/integration/Setup/SetupScript.php @@ -21,61 +21,24 @@ class SetupScript { use UsesTmpDir; - /** - * Test database host. - * - * @var string - */ - protected $host; + protected string $driver; + protected string $host; + protected int $port; + protected string $name; + protected string $user; + protected string $pass; + protected string $pref; - /** - * Test database port. - * - * @var int - */ - protected $port; - - /** - * Test database name. - * - * @var string - */ - protected $name; - - /** - * Test database username. - * - * @var string - */ - protected $user; - - /** - * Test database password. - * - * @var string - */ - protected $pass; - - /** - * Test database prefix. - * - * @var string - */ - protected $pref; - - /** - * @var DatabaseConfig - */ - protected $dbConfig; + protected DatabaseConfig $dbConfig; /** * Settings to be applied during installation. - * @var array */ - protected $settings = ['mail_driver' => 'log']; + protected array $settings = ['mail_driver' => 'log']; public function __construct() { + $this->driver = getenv('DB_DRIVER') ?: 'mysql'; $this->host = getenv('DB_HOST') ?: 'localhost'; $this->port = intval(getenv('DB_PORT') ?: 3306); $this->name = getenv('DB_DATABASE') ?: 'flarum_test'; @@ -88,7 +51,12 @@ public function run() { $tmp = $this->tmpDir(); - echo "Connecting to database $this->name at $this->host:$this->port.\n"; + if ($this->driver === 'sqlite') { + echo "Connecting to SQLite database at $this->name.\n"; + } else { + echo "Connecting to database $this->name at $this->host:$this->port.\n"; + } + echo "Warning: all tables will be dropped to ensure clean state. DO NOT use your production database!\n"; echo "Logging in as $this->user with password '$this->pass'.\n"; echo "Table prefix: '$this->pref'\n"; @@ -103,22 +71,31 @@ public function run() echo "\nOff we go...\n"; - $this->dbConfig = new DatabaseConfig('mysql', $this->host, $this->port, $this->name, $this->user, $this->pass, $this->pref); + $this->dbConfig = new DatabaseConfig( + $this->driver, + $this->host, + $this->port, + $this->name, + $this->user, + $this->pass, + $this->pref + ); - echo "\nWiping DB to ensure clean state\n"; - $this->wipeDb(); - echo "Success! Proceeding to installation...\n"; + $paths = new Paths([ + 'base' => $tmp, + 'public' => "$tmp/public", + 'storage' => "$tmp/storage", + 'vendor' => getenv('FLARUM_TEST_VENDOR_PATH') ?: getcwd().'/vendor', + ]); $this->setupTmpDir(); + $this->dbConfig->prepare($paths); - $installation = new Installation( - new Paths([ - 'base' => $tmp, - 'public' => "$tmp/public", - 'storage' => "$tmp/storage", - 'vendor' => getenv('FLARUM_TEST_VENDOR_PATH') ?: getcwd().'/vendor', - ]) - ); + echo "\nWiping DB to ensure clean state\n"; + $this->wipeDb($paths); + echo "Success! Proceeding to installation...\n"; + + $installation = new Installation($paths); $pipeline = $installation ->configPath('config.php') @@ -140,7 +117,7 @@ public function run() echo "Installation complete\n"; } - protected function wipeDb() + protected function wipeDb(Paths $paths) { // Reuse the connection step to include version checks (new ConnectToDatabase($this->dbConfig, function ($db) { @@ -149,7 +126,7 @@ protected function wipeDb() $builder->dropAllTables(); $builder->dropAllViews(); - }))->run(); + }, $paths->base))->run(); } /** diff --git a/php-packages/testing/src/integration/TestCase.php b/php-packages/testing/src/integration/TestCase.php index 68f2d4764a..b450be491d 100644 --- a/php-packages/testing/src/integration/TestCase.php +++ b/php-packages/testing/src/integration/TestCase.php @@ -193,7 +193,12 @@ protected function prepareDatabase(array $tableData): void protected function populateDatabase(): void { - // We temporarily disable foreign key checks to simplify this process. + /** + * We temporarily disable foreign key checks to simplify this process. + * SQLite ignores this statement since we are inside a transaction. + * So we do that before starting a transaction. + * @see BeginTransactionAndSetDatabase + */ $this->database()->getSchemaBuilder()->disableForeignKeyConstraints(); $databaseContent = [];