diff --git a/.github/workflows/REUSABLE_backend.yml b/.github/workflows/REUSABLE_backend.yml index 70adb15b8..46e823c8e 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", "sqlite:3"]' + default: '["mysql:5.7", "mysql:8.0.30", "mysql:8.1.0", "mariadb", "sqlite:3", "postgres:10"]' php_ini_values: description: PHP ini values @@ -68,6 +68,9 @@ env: # `inputs.composer_directory` defaults to `inputs.backend_directory` FLARUM_TEST_TMP_DIR_LOCAL: tests/integration/tmp COMPOSER_AUTH: ${{ secrets.composer_auth }} + DB_DATABASE: flarum_test + DB_USERNAME: root + DB_PASSWORD: root jobs: test: @@ -98,6 +101,9 @@ jobs: - service: 'sqlite:3' db: SQLite driver: sqlite + - service: 'postgres:10' + db: PostgreSQL 10 + driver: pgsql # Include Database prefix tests with only one PHP version. - php: ${{ fromJSON(inputs.php_versions)[0] }} @@ -106,30 +112,24 @@ jobs: 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) + - php: ${{ fromJSON(inputs.php_versions)[0] }} + service: 'postgres:10' + db: PostgreSQL 10 + driver: pgsql + prefix: flarum_ + prefixStr: (prefix) # To reduce number of actions, we exclude some PHP versions from running with some DB versions. exclude: @@ -147,12 +147,34 @@ jobs: service: 'sqlite:3' - php: ${{ fromJSON(inputs.php_versions)[1] }} service: 'sqlite:3' + - php: ${{ fromJSON(inputs.php_versions)[0] }} + service: 'postgres:10' + - php: ${{ fromJSON(inputs.php_versions)[1] }} + service: 'postgres:10' services: mysql: - image: ${{ matrix.service != 'sqlite:3' && matrix.service || '' }} + image: ${{ matrix.driver == 'mysql' && matrix.service || '' }} + env: + MYSQL_DATABASE: ${{ env.DB_DATABASE }} + MYSQL_USER: ${{ env.DB_USERNAME }} + MYSQL_PASSWORD: ${{ env.DB_PASSWORD }} + MYSQL_ROOT_PASSWORD: ${{ env.DB_PASSWORD }} ports: - 13306:3306 + postgres: + image: ${{ matrix.driver == 'pgsql' && matrix.service || '' }} + env: + POSTGRES_DB: ${{ env.DB_DATABASE }} + POSTGRES_USER: ${{ env.DB_USERNAME }} + POSTGRES_PASSWORD: ${{ env.DB_PASSWORD }} + ports: + - 15432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 name: 'PHP ${{ matrix.php }} / ${{ matrix.db }} ${{ matrix.prefixStr }}' @@ -173,7 +195,7 @@ jobs: ini-values: ${{ matrix.php_ini_values }} - name: Create MySQL Database - if: ${{ matrix.service != 'sqlite:3' }} + if: ${{ matrix.driver == 'mysql' }} run: | sudo systemctl start mysql mysql -uroot -proot -e 'CREATE DATABASE flarum_test;' --port 13306 @@ -200,8 +222,7 @@ jobs: fi working-directory: ${{ inputs.backend_directory }} env: - DB_PORT: 13306 - DB_PASSWORD: root + DB_PORT: ${{ matrix.driver == 'mysql' && 13306 || 15432 }} DB_PREFIX: ${{ matrix.prefix }} DB_DRIVER: ${{ matrix.driver }} COMPOSER_PROCESS_TIMEOUT: 600 diff --git a/extensions/approval/tests/integration/api/ApprovePostsTest.php b/extensions/approval/tests/integration/api/ApprovePostsTest.php index 6a9b00088..5d8a322d8 100644 --- a/extensions/approval/tests/integration/api/ApprovePostsTest.php +++ b/extensions/approval/tests/integration/api/ApprovePostsTest.php @@ -11,8 +11,12 @@ namespace Flarum\Approval\Tests\integration\api; use Carbon\Carbon; use Flarum\Approval\Tests\integration\InteractsWithUnapprovedContent; +use Flarum\Discussion\Discussion; +use Flarum\Group\Group; +use Flarum\Post\Post; use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\TestCase; +use Flarum\User\User; class ApprovePostsTest extends TestCase { @@ -26,23 +30,23 @@ class ApprovePostsTest extends TestCase $this->extension('flarum-approval'); $this->prepareDatabase([ - 'users' => [ + User::class => [ ['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1], $this->normalUser(), ['id' => 3, 'username' => 'acme', 'email' => 'acme@machine.local', 'is_email_confirmed' => 1], ['id' => 4, 'username' => 'luceos', 'email' => 'luceos@machine.local', 'is_email_confirmed' => 1], ], - 'discussions' => [ + Discussion::class => [ ['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 1, 'comment_count' => 1, 'is_approved' => 1], ], - 'posts' => [ - ['id' => 1, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'hidden_at' => 0, 'is_approved' => 1, 'number' => 1], - ['id' => 2, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'hidden_at' => 0, 'is_approved' => 1, 'number' => 2], - ['id' => 3, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'hidden_at' => 0, 'is_approved' => 0, 'number' => 3], + Post::class => [ + ['id' => 1, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'hidden_at' => null, 'is_approved' => 1, 'number' => 1], + ['id' => 2, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'hidden_at' => null, 'is_approved' => 1, 'number' => 2], + ['id' => 3, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'hidden_at' => null, 'is_approved' => 0, 'number' => 3], ['id' => 4, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'hidden_at' => Carbon::now(), 'is_approved' => 1, 'number' => 4], - ['id' => 5, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'hidden_at' => 0, 'is_approved' => 0, 'number' => 5], + ['id' => 5, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'hidden_at' => null, 'is_approved' => 0, 'number' => 5], ], - 'groups' => [ + Group::class => [ ['id' => 4, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0], ['id' => 5, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0], ], diff --git a/extensions/approval/tests/integration/api/CreatePostsTest.php b/extensions/approval/tests/integration/api/CreatePostsTest.php index 82b8857f9..7099f3fa9 100644 --- a/extensions/approval/tests/integration/api/CreatePostsTest.php +++ b/extensions/approval/tests/integration/api/CreatePostsTest.php @@ -11,9 +11,12 @@ namespace Flarum\Approval\Tests\integration\api; use Carbon\Carbon; use Flarum\Approval\Tests\integration\InteractsWithUnapprovedContent; +use Flarum\Discussion\Discussion; use Flarum\Group\Group; +use Flarum\Post\Post; use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\TestCase; +use Flarum\User\User; class CreatePostsTest extends TestCase { @@ -27,18 +30,18 @@ class CreatePostsTest extends TestCase $this->extension('flarum-flags', 'flarum-approval'); $this->prepareDatabase([ - 'users' => [ + User::class => [ ['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1], $this->normalUser(), ['id' => 3, 'username' => 'acme', 'email' => 'acme@machine.local', 'is_email_confirmed' => 1], ['id' => 4, 'username' => 'luceos', 'email' => 'luceos@machine.local', 'is_email_confirmed' => 1], ], - 'discussions' => [ + Discussion::class => [ ['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 1, 'comment_count' => 1, 'is_approved' => 1], ['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 2, 'comment_count' => 1, 'is_approved' => 0], ['id' => 3, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 3, 'comment_count' => 1, 'is_approved' => 0], ], - 'posts' => [ + Post::class => [ ['id' => 1, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'is_approved' => 1, 'number' => 1], ['id' => 2, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'is_approved' => 1, 'number' => 2], ['id' => 3, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'is_approved' => 1, 'number' => 3], @@ -49,7 +52,7 @@ class CreatePostsTest extends TestCase ['id' => 8, 'discussion_id' => 3, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'is_approved' => 1, 'number' => 2], ['id' => 9, 'discussion_id' => 3, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'is_approved' => 0, 'number' => 3], ], - 'groups' => [ + Group::class => [ ['id' => 4, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0], ['id' => 5, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0], ], @@ -60,6 +63,7 @@ class CreatePostsTest extends TestCase 'group_permission' => [ ['group_id' => 4, 'permission' => 'discussion.startWithoutApproval'], ['group_id' => 5, 'permission' => 'discussion.replyWithoutApproval'], + ['group_id' => Group::MEMBER_ID, 'permission' => 'postWithoutThrottle'], ] ]); } diff --git a/extensions/flags/src/Api/Resource/FlagResource.php b/extensions/flags/src/Api/Resource/FlagResource.php index a124abd52..aec1aefd0 100644 --- a/extensions/flags/src/Api/Resource/FlagResource.php +++ b/extensions/flags/src/Api/Resource/FlagResource.php @@ -53,7 +53,10 @@ class FlagResource extends AbstractDatabaseResource public function query(Context $context): object { if ($context->listing(self::class)) { - $query = Flag::query()->groupBy('post_id'); + $query = Flag::query()->whenPgSql( + fn (Builder $query) => $query->distinct('post_id')->orderBy('post_id'), + else: fn (Builder $query) => $query->groupBy('post_id') + ); $this->scope($query, $context); diff --git a/extensions/flags/tests/integration/api/flags/ListTest.php b/extensions/flags/tests/integration/api/flags/ListTest.php index 6f137c265..57f21344d 100644 --- a/extensions/flags/tests/integration/api/flags/ListTest.php +++ b/extensions/flags/tests/integration/api/flags/ListTest.php @@ -9,6 +9,7 @@ namespace Flarum\Flags\Tests\integration\api\flags; +use Carbon\Carbon; use Flarum\Discussion\Discussion; use Flarum\Flags\Flag; use Flarum\Group\Group; @@ -16,6 +17,7 @@ use Flarum\Post\Post; use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\TestCase; use Flarum\User\User; +use Illuminate\Database\PostgresConnection; use Illuminate\Support\Arr; class ListTest extends TestCase @@ -58,12 +60,12 @@ class ListTest extends TestCase ['id' => 4, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '

', 'is_private' => true], ], 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], - ['id' => 4, 'post_id' => 2, 'user_id' => 2], - ['id' => 5, 'post_id' => 3, 'user_id' => 1], - ['id' => 6, 'post_id' => 4, 'user_id' => 1], + ['id' => 1, 'post_id' => 1, 'user_id' => 1, 'created_at' => Carbon::now()->addMinutes(2)], + ['id' => 2, 'post_id' => 1, 'user_id' => 2, 'created_at' => Carbon::now()->addMinutes(3)], + ['id' => 3, 'post_id' => 1, 'user_id' => 3, 'created_at' => Carbon::now()->addMinutes(4)], + ['id' => 4, 'post_id' => 2, 'user_id' => 2, 'created_at' => Carbon::now()->addMinutes(5)], + ['id' => 5, 'post_id' => 3, 'user_id' => 1, 'created_at' => Carbon::now()->addMinutes(6)], + ['id' => 6, 'post_id' => 4, 'user_id' => 1, 'created_at' => Carbon::now()->addMinutes(7)], ] ]); } @@ -79,12 +81,19 @@ class ListTest extends TestCase ]) ); - $this->assertEquals(200, $response->getStatusCode(), $body = $response->getBody()->getContents()); + $body = $response->getBody()->getContents(); + + $this->assertEquals(200, $response->getStatusCode(), $body); $data = json_decode($body, true)['data']; $ids = Arr::pluck($data, 'id'); - $this->assertEqualsCanonicalizing(['1', '4', '5'], $ids); + + if ($this->database() instanceof PostgresConnection) { + $this->assertEqualsCanonicalizing(['3', '4', '5'], $ids); + } else { + $this->assertEqualsCanonicalizing(['1', '4', '5'], $ids); + } } /** @@ -122,7 +131,7 @@ class ListTest extends TestCase $data = json_decode($response->getBody()->getContents(), true)['data']; $ids = Arr::pluck($data, 'id'); - $this->assertEqualsCanonicalizing(['1', '4', '5'], $ids); + $this->assertCount(3, $data); } /** diff --git a/extensions/flags/tests/integration/api/flags/ListWithTagsTest.php b/extensions/flags/tests/integration/api/flags/ListWithTagsTest.php index dda8f27ed..e1a6526cb 100644 --- a/extensions/flags/tests/integration/api/flags/ListWithTagsTest.php +++ b/extensions/flags/tests/integration/api/flags/ListWithTagsTest.php @@ -18,6 +18,7 @@ use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\TestCase; use Flarum\User\User; use Illuminate\Support\Arr; +use Illuminate\Support\Carbon; class ListWithTagsTest extends TestCase { @@ -86,16 +87,16 @@ class ListWithTagsTest extends TestCase ], Flag::class => [ // From regular ListTest - ['id' => 1, 'post_id' => 1, 'user_id' => 1], - ['id' => 2, 'post_id' => 1, 'user_id' => 2], - ['id' => 3, 'post_id' => 1, 'user_id' => 3], - ['id' => 4, 'post_id' => 2, 'user_id' => 2], - ['id' => 5, 'post_id' => 3, 'user_id' => 1], + ['id' => 1, 'post_id' => 1, 'user_id' => 1, 'created_at' => Carbon::now()->addMinutes(2)], + ['id' => 2, 'post_id' => 1, 'user_id' => 2, 'created_at' => Carbon::now()->addMinutes(3)], + ['id' => 3, 'post_id' => 1, 'user_id' => 3, 'created_at' => Carbon::now()->addMinutes(4)], + ['id' => 4, 'post_id' => 2, 'user_id' => 2, 'created_at' => Carbon::now()->addMinutes(5)], + ['id' => 5, 'post_id' => 3, 'user_id' => 1, 'created_at' => Carbon::now()->addMinutes(6)], // In tags - ['id' => 6, 'post_id' => 4, 'user_id' => 1], - ['id' => 7, 'post_id' => 5, 'user_id' => 1], - ['id' => 8, 'post_id' => 6, 'user_id' => 1], - ['id' => 9, 'post_id' => 7, 'user_id' => 1], + ['id' => 6, 'post_id' => 4, 'user_id' => 1, 'created_at' => Carbon::now()->addMinutes(7)], + ['id' => 7, 'post_id' => 5, 'user_id' => 1, 'created_at' => Carbon::now()->addMinutes(8)], + ['id' => 8, 'post_id' => 6, 'user_id' => 1, 'created_at' => Carbon::now()->addMinutes(9)], + ['id' => 9, 'post_id' => 7, 'user_id' => 1, 'created_at' => Carbon::now()->addMinutes(10)], ] ]); } @@ -111,12 +112,14 @@ class ListWithTagsTest extends TestCase ]) ); - $this->assertEquals(200, $response->getStatusCode()); + $body = $response->getBody()->getContents(); - $data = json_decode($response->getBody()->getContents(), true)['data']; + $this->assertEquals(200, $response->getStatusCode(), $body); + + $data = json_decode($body, true)['data']; $ids = Arr::pluck($data, 'id'); - $this->assertEqualsCanonicalizing(['1', '4', '5', '6', '7', '8', '9'], $ids); + $this->assertCount(7, $data); } /** @@ -154,7 +157,9 @@ class ListWithTagsTest extends TestCase $data = json_decode($response->getBody()->getContents(), true)['data']; $ids = Arr::pluck($data, 'id'); - $this->assertEqualsCanonicalizing(['1', '4', '5', '8', '9'], $ids); + // 7 is included, even though mods can't view discussions. + // This is because the UI doesnt allow discussions.viewFlags without viewDiscussions. + $this->assertCount(5, $data); } /** diff --git a/extensions/flags/tests/integration/api/posts/IncludeFlagsVisibilityTest.php b/extensions/flags/tests/integration/api/posts/IncludeFlagsVisibilityTest.php index 7ae42b513..02100a7d8 100644 --- a/extensions/flags/tests/integration/api/posts/IncludeFlagsVisibilityTest.php +++ b/extensions/flags/tests/integration/api/posts/IncludeFlagsVisibilityTest.php @@ -9,9 +9,14 @@ namespace Flarum\Flags\Tests\integration\api\posts; +use Flarum\Discussion\Discussion; +use Flarum\Flags\Flag; use Flarum\Group\Group; +use Flarum\Post\Post; +use Flarum\Tags\Tag; use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\TestCase; +use Flarum\User\User; use Illuminate\Support\Arr; class IncludeFlagsVisibilityTest extends TestCase @@ -28,7 +33,7 @@ class IncludeFlagsVisibilityTest extends TestCase $this->extension('flarum-tags', 'flarum-flags'); $this->prepareDatabase([ - 'users' => [ + User::class => [ $this->normalUser(), [ 'id' => 3, @@ -56,7 +61,7 @@ class IncludeFlagsVisibilityTest extends TestCase ['group_id' => 5, 'user_id' => 2], ['group_id' => 6, 'user_id' => 3], ], - 'groups' => [ + Group::class => [ ['id' => 5, 'name_singular' => 'group5', 'name_plural' => 'group5', 'color' => null, 'icon' => 'fas fa-crown', 'is_hidden' => false], ['id' => 6, 'name_singular' => 'group1', 'name_plural' => 'group1', 'color' => null, 'icon' => 'fas fa-cog', 'is_hidden' => false], ], @@ -67,11 +72,11 @@ class IncludeFlagsVisibilityTest extends TestCase ['group_id' => 6, 'permission' => 'tag1.discussion.viewFlags'], ['group_id' => 6, 'permission' => 'tag1.viewForum'], ], - 'tags' => [ + Tag::class => [ ['id' => 1, 'name' => 'Tag 1', 'slug' => 'tag-1', 'is_primary' => false, 'position' => null, 'parent_id' => null, 'is_restricted' => true], ['id' => 2, 'name' => 'Tag 2', 'slug' => 'tag-2', 'is_primary' => true, 'position' => 2, 'parent_id' => null, 'is_restricted' => false], ], - 'discussions' => [ + Discussion::class => [ ['id' => 1, 'title' => 'Test1', 'user_id' => 1, 'comment_count' => 1], ['id' => 2, 'title' => 'Test2', 'user_id' => 1, 'comment_count' => 1], ], @@ -79,7 +84,7 @@ class IncludeFlagsVisibilityTest extends TestCase ['discussion_id' => 1, 'tag_id' => 1], ['discussion_id' => 2, 'tag_id' => 2], ], - 'posts' => [ + Post::class => [ ['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '

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

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

'], @@ -87,7 +92,7 @@ class IncludeFlagsVisibilityTest extends TestCase ['id' => 4, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '

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

'], ], - 'flags' => [ + Flag::class => [ ['id' => 1, 'post_id' => 1, 'user_id' => 1], ['id' => 2, 'post_id' => 1, 'user_id' => 5], ['id' => 3, 'post_id' => 1, 'user_id' => 3], diff --git a/extensions/likes/src/Api/PostResourceFields.php b/extensions/likes/src/Api/PostResourceFields.php index d40e9f628..9f16b286f 100644 --- a/extensions/likes/src/Api/PostResourceFields.php +++ b/extensions/likes/src/Api/PostResourceFields.php @@ -58,6 +58,7 @@ class PostResourceFields // So that we can tell if the current user has liked the post. $query ->orderBy(new Expression($grammar->wrap('user_id').' = '.$actor->id), 'desc') + ->orderBy('created_at') ->limit(static::$maxLikes); }), ]; diff --git a/extensions/likes/tests/integration/api/ListPostsTest.php b/extensions/likes/tests/integration/api/ListPostsTest.php index 5c8df77b9..37c5a8415 100644 --- a/extensions/likes/tests/integration/api/ListPostsTest.php +++ b/extensions/likes/tests/integration/api/ListPostsTest.php @@ -54,17 +54,17 @@ class ListPostsTest extends TestCase ['id' => 112, 'username' => 'user112', 'email' => '112@machine.local', 'is_email_confirmed' => 1], ], 'post_likes' => [ - ['user_id' => 102, 'post_id' => 101], - ['user_id' => 104, 'post_id' => 101], - ['user_id' => 105, 'post_id' => 101], - ['user_id' => 106, 'post_id' => 101], - ['user_id' => 107, 'post_id' => 101], - ['user_id' => 108, 'post_id' => 101], - ['user_id' => 109, 'post_id' => 101], - ['user_id' => 110, 'post_id' => 101], - ['user_id' => 2, 'post_id' => 101], - ['user_id' => 111, 'post_id' => 101], - ['user_id' => 112, 'post_id' => 101], + ['user_id' => 102, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(2)], + ['user_id' => 104, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(3)], + ['user_id' => 105, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(4)], + ['user_id' => 106, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(5)], + ['user_id' => 107, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(6)], + ['user_id' => 108, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(7)], + ['user_id' => 109, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(8)], + ['user_id' => 110, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(9)], + ['user_id' => 2, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(10)], + ['user_id' => 111, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(11)], + ['user_id' => 112, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(12)], ], 'group_permission' => [ ['group_id' => Group::GUEST_ID, 'permission' => 'searchUsers'], diff --git a/extensions/lock/src/Filter/LockedFilter.php b/extensions/lock/src/Filter/LockedFilter.php index 6fb2349f5..6d5cb2c2d 100644 --- a/extensions/lock/src/Filter/LockedFilter.php +++ b/extensions/lock/src/Filter/LockedFilter.php @@ -12,7 +12,7 @@ namespace Flarum\Lock\Filter; use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; -use Illuminate\Database\Query\Builder; +use Illuminate\Database\Eloquent\Builder; /** * @implements FilterInterface diff --git a/extensions/mentions/tests/integration/api/GroupMentionsTest.php b/extensions/mentions/tests/integration/api/GroupMentionsTest.php index 0256e947d..6be5870a1 100644 --- a/extensions/mentions/tests/integration/api/GroupMentionsTest.php +++ b/extensions/mentions/tests/integration/api/GroupMentionsTest.php @@ -249,9 +249,11 @@ class GroupMentionsTest extends TestCase ]) ); - $this->assertEquals(201, $response->getStatusCode()); + $body = $response->getBody()->getContents(); - $response = json_decode($response->getBody(), true); + $this->assertEquals(201, $response->getStatusCode(), $body); + + $response = json_decode($body, true); $this->assertStringNotContainsString('@Members', $response['data']['attributes']['contentHtml']); $this->assertStringNotContainsString('@Guests', $response['data']['attributes']['contentHtml']); diff --git a/extensions/mentions/tests/integration/api/ListPostsTest.php b/extensions/mentions/tests/integration/api/ListPostsTest.php index f33abc2fc..d5214aebb 100644 --- a/extensions/mentions/tests/integration/api/ListPostsTest.php +++ b/extensions/mentions/tests/integration/api/ListPostsTest.php @@ -116,35 +116,35 @@ class ListPostsTest extends TestCase { $this->prepareDatabase([ Discussion::class => [ - ['id' => 100, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 101, 'comment_count' => 12], + ['id' => 100, 'title' => __CLASS__, 'created_at' => Carbon::parse('2024-05-04'), 'user_id' => 1, 'first_post_id' => 101, 'comment_count' => 12], ], Post::class => [ - ['id' => 101, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], - ['id' => 102, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], - ['id' => 103, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

', 'is_private' => 1], - ['id' => 104, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], - ['id' => 105, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], - ['id' => 106, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], - ['id' => 107, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], - ['id' => 108, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], - ['id' => 109, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], - ['id' => 110, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], - ['id' => 111, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], - ['id' => 112, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], + ['id' => 101, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04'), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], + ['id' => 102, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(2), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], + ['id' => 103, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(3), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

', 'is_private' => 1], + ['id' => 104, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(4), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], + ['id' => 105, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(5), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], + ['id' => 106, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(6), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], + ['id' => 107, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(7), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], + ['id' => 108, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(8), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], + ['id' => 109, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(9), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], + ['id' => 110, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(10), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], + ['id' => 111, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(11), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], + ['id' => 112, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(12), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], ], 'post_mentions_post' => [ - ['post_id' => 102, 'mentions_post_id' => 101], - ['post_id' => 103, 'mentions_post_id' => 101], - ['post_id' => 104, 'mentions_post_id' => 101], - ['post_id' => 105, 'mentions_post_id' => 101], - ['post_id' => 106, 'mentions_post_id' => 101], - ['post_id' => 107, 'mentions_post_id' => 101], - ['post_id' => 108, 'mentions_post_id' => 101], - ['post_id' => 109, 'mentions_post_id' => 101], - ['post_id' => 110, 'mentions_post_id' => 101], - ['post_id' => 111, 'mentions_post_id' => 101], - ['post_id' => 112, 'mentions_post_id' => 101], - ['post_id' => 103, 'mentions_post_id' => 112], + ['post_id' => 102, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(2)], + ['post_id' => 103, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(3)], + ['post_id' => 104, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(4)], + ['post_id' => 105, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(5)], + ['post_id' => 106, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(6)], + ['post_id' => 107, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(7)], + ['post_id' => 108, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(8)], + ['post_id' => 109, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(9)], + ['post_id' => 110, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(10)], + ['post_id' => 111, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(11)], + ['post_id' => 112, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(12)], + ['post_id' => 103, 'mentions_post_id' => 112, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(13)], ], ]); } @@ -187,10 +187,11 @@ class ListPostsTest extends TestCase ])->withQueryParams([ 'filter' => ['discussion' => 100], 'include' => 'mentionedBy', + 'sort' => 'createdAt', ]) ); - $data = json_decode($body = $response->getBody()->getContents(), true)['data'] ?? []; + $data = json_decode($body = $response->getBody()->getContents(), true)['data']; $this->assertEquals(200, $response->getStatusCode(), $body); diff --git a/extensions/mentions/tests/integration/api/PostMentionsTest.php b/extensions/mentions/tests/integration/api/PostMentionsTest.php index 822eaf1ad..a408b4a3b 100644 --- a/extensions/mentions/tests/integration/api/PostMentionsTest.php +++ b/extensions/mentions/tests/integration/api/PostMentionsTest.php @@ -51,7 +51,7 @@ class PostMentionsTest extends TestCase ['id' => 8, 'number' => 6, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '@"i_am_a_deleted_user"#p2020'], ['id' => 9, 'number' => 10, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 5, 'type' => 'comment', 'content' => '

I am bad

'], ['id' => 10, 'number' => 11, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '@"Bad "#p6 User"#p9'], - ['id' => 11, 'number' => 12, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 40, 'type' => 'comment', 'content' => '@"Bad "#p6 User"#p9'], + ['id' => 11, 'number' => 12, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => null, 'type' => 'comment', 'content' => '@"Bad "#p6 User"#p9'], ['id' => 12, 'number' => 13, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '@"acme"#p11'], // Restricted access @@ -95,9 +95,11 @@ class PostMentionsTest extends TestCase ]) ); - $this->assertEquals(201, $response->getStatusCode()); + $body = $response->getBody()->getContents(); - $response = json_decode($response->getBody(), true); + $this->assertEquals(201, $response->getStatusCode(), $body); + + $response = json_decode($body, true); $this->assertStringNotContainsString('POTATO$', $response['data']['attributes']['contentHtml']); $this->assertEquals('@potato#4', $response['data']['attributes']['content']); @@ -191,9 +193,11 @@ class PostMentionsTest extends TestCase ]) ); - $this->assertEquals(201, $response->getStatusCode()); + $body = $response->getBody()->getContents(); - $response = json_decode($response->getBody(), true); + $this->assertEquals(201, $response->getStatusCode(), $body); + + $response = json_decode($body, true); $this->assertStringContainsString('POTATO$', $response['data']['attributes']['contentHtml']); $this->assertEquals('@"POTATO$"#p4', $response['data']['attributes']['content']); @@ -514,9 +518,11 @@ class PostMentionsTest extends TestCase ]) ); - $this->assertEquals(200, $response->getStatusCode()); + $body = $response->getBody()->getContents(); - $response = json_decode($response->getBody(), true); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $response = json_decode($body, true); $this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']); $this->assertEquals('@"Bad _ User"#p9', $response['data']['attributes']['content']); diff --git a/extensions/mentions/tests/integration/api/UserMentionsTest.php b/extensions/mentions/tests/integration/api/UserMentionsTest.php index 050e8275b..726b0c21f 100644 --- a/extensions/mentions/tests/integration/api/UserMentionsTest.php +++ b/extensions/mentions/tests/integration/api/UserMentionsTest.php @@ -38,6 +38,7 @@ class UserMentionsTest extends TestCase ['id' => 3, 'username' => 'potato', 'email' => 'potato@machine.local', 'is_email_confirmed' => 1], ['id' => 4, 'username' => 'toby', 'email' => 'toby@machine.local', 'is_email_confirmed' => 1], ['id' => 5, 'username' => 'bad_user', 'email' => 'bad_user@machine.local', 'is_email_confirmed' => 1], + ['id' => 50] ], Discussion::class => [ ['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 3, 'first_post_id' => 4, 'comment_count' => 2], @@ -500,9 +501,11 @@ class UserMentionsTest extends TestCase ]) ); - $this->assertEquals(200, $response->getStatusCode()); + $body = $response->getBody()->getContents(); - $response = json_decode($response->getBody(), true); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $response = json_decode($body, true); $this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']); $this->assertEquals('@"Bad _ User"#5', $response['data']['attributes']['content']); diff --git a/extensions/statistics/src/Api/Controller/ShowStatisticsData.php b/extensions/statistics/src/Api/Controller/ShowStatisticsData.php index 8938b0382..e90d6c0ab 100644 --- a/extensions/statistics/src/Api/Controller/ShowStatisticsData.php +++ b/extensions/statistics/src/Api/Controller/ShowStatisticsData.php @@ -11,6 +11,7 @@ namespace Flarum\Statistics\Api\Controller; use Carbon\Carbon; use DateTime; +use Exception; use Flarum\Discussion\Discussion; use Flarum\Http\Exception\InvalidParameterException; use Flarum\Http\RequestUtil; @@ -130,11 +131,19 @@ class ShowStatisticsData implements RequestHandlerInterface $endDate = new DateTime(); } + $formats = match ($query->getConnection()->getDriverName()) { + 'pgsql' => ['YYYY-MM-DD HH24:00:00', 'YYYY-MM-DD'], + default => ['%Y-%m-%d %H:00:00', '%Y-%m-%d'], + }; + // 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'; + $format = "CASE WHEN $column > ? THEN '$formats[0]' ELSE '$formats[1]' END"; + $dbFormattedDatetime = match ($query->getConnection()->getDriverName()) { - 'sqlite' => 'strftime('.$format.', '.$column.')', - default => 'DATE_FORMAT('.$column.', '.$format.')', + 'sqlite' => "strftime($format, $column)", + 'pgsql' => "TO_CHAR($column, $format)", + 'mysql' => "DATE_FORMAT($column, $format)", + default => throw new Exception('Unsupported database driver'), }; $results = $query diff --git a/extensions/sticky/src/PinStickiedDiscussionsToTop.php b/extensions/sticky/src/PinStickiedDiscussionsToTop.php index a8b57eebc..dcb53e355 100755 --- a/extensions/sticky/src/PinStickiedDiscussionsToTop.php +++ b/extensions/sticky/src/PinStickiedDiscussionsToTop.php @@ -9,6 +9,7 @@ namespace Flarum\Sticky; +use DateTime; use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\SearchCriteria; use Flarum\Tags\Search\Filter\TagFilter; @@ -19,7 +20,7 @@ class PinStickiedDiscussionsToTop public function __invoke(DatabaseSearchState $state, SearchCriteria $criteria): void { if ($criteria->sortIsDefault && ! $state->isFulltextSearch()) { - $query = $state->getQuery(); + $query = $state->getQuery()->getQuery(); // If we are viewing a specific tag, then pin all stickied // discussions to the top no matter what. @@ -46,6 +47,8 @@ class PinStickiedDiscussionsToTop $sticky->where('is_sticky', true); unset($sticky->orders); + $epochTime = (new DateTime('@0'))->format('Y-m-d H:i:s'); + /** @var Builder $q */ foreach ([$sticky, $query] as $q) { $read = $q->newQuery() @@ -58,7 +61,7 @@ class PinStickiedDiscussionsToTop // 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])); + $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 ?: $epochTime])); } $query->union($sticky); diff --git a/extensions/sticky/src/Query/StickyFilter.php b/extensions/sticky/src/Query/StickyFilter.php index 74ab036eb..af008f649 100644 --- a/extensions/sticky/src/Query/StickyFilter.php +++ b/extensions/sticky/src/Query/StickyFilter.php @@ -12,7 +12,7 @@ namespace Flarum\Sticky\Query; use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; -use Illuminate\Database\Query\Builder; +use Illuminate\Database\Eloquent\Builder; /** * @implements FilterInterface diff --git a/extensions/sticky/tests/integration/api/ListDiscussionsTest.php b/extensions/sticky/tests/integration/api/ListDiscussionsTest.php index 85ab86c27..de1b15cc2 100644 --- a/extensions/sticky/tests/integration/api/ListDiscussionsTest.php +++ b/extensions/sticky/tests/integration/api/ListDiscussionsTest.php @@ -62,11 +62,13 @@ class ListDiscussionsTest extends TestCase $this->request('GET', '/api/discussions') ); - $this->assertEquals(200, $response->getStatusCode(), $body = $response->getBody()->getContents()); + $body = $response->getBody()->getContents(); + + $this->assertEquals(200, $response->getStatusCode(), $body); $data = json_decode($body, true); - $this->assertEqualsCanonicalizing([3, 1, 2, 4], Arr::pluck($data['data'], 'id')); + $this->assertEquals([3, 1, 2, 4], Arr::pluck($data['data'], 'id')); } /** @test */ @@ -114,10 +116,12 @@ class ListDiscussionsTest extends TestCase ]) ); - $this->assertEquals(200, $response->getStatusCode(), $body = $response->getBody()->getContents()); + $body = $response->getBody()->getContents(); + + $this->assertEquals(200, $response->getStatusCode(), $body); $data = json_decode($body, true); - $this->assertEqualsCanonicalizing([3, 1, 2, 4], Arr::pluck($data['data'], 'id')); + $this->assertEquals([3, 1, 2, 4], Arr::pluck($data['data'], 'id')); } } diff --git a/extensions/sticky/tests/integration/api/StickyDiscussionsTest.php b/extensions/sticky/tests/integration/api/StickyDiscussionsTest.php index 1554ec37a..ec835c624 100644 --- a/extensions/sticky/tests/integration/api/StickyDiscussionsTest.php +++ b/extensions/sticky/tests/integration/api/StickyDiscussionsTest.php @@ -10,8 +10,12 @@ namespace Flarum\Sticky\Tests\integration\api; use Carbon\Carbon; +use Flarum\Discussion\Discussion; +use Flarum\Group\Group; +use Flarum\Post\Post; use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\TestCase; +use Flarum\User\User; class StickyDiscussionsTest extends TestCase { @@ -24,18 +28,24 @@ class StickyDiscussionsTest extends TestCase $this->extension('flarum-sticky'); $this->prepareDatabase([ - 'users' => [ + User::class => [ ['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1], $this->normalUser(), ['id' => 3, 'username' => 'Muralf_', 'email' => 'muralf_@machine.local', 'is_email_confirmed' => 1], ], - 'discussions' => [ + Discussion::class => [ ['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'is_sticky' => true, 'last_post_number' => 1], - ['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now()->addMinutes(2), 'last_posted_at' => Carbon::now()->addMinutes(5), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'is_sticky' => false, 'last_post_number' => 1], - ['id' => 3, 'title' => __CLASS__, 'created_at' => Carbon::now()->addMinutes(3), 'last_posted_at' => Carbon::now()->addMinute(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'is_sticky' => true, 'last_post_number' => 1], - ['id' => 4, 'title' => __CLASS__, 'created_at' => Carbon::now()->addMinutes(4), 'last_posted_at' => Carbon::now()->addMinutes(2), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'is_sticky' => false, 'last_post_number' => 1], + ['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now()->addMinutes(2), 'last_posted_at' => Carbon::now()->addMinutes(5), 'user_id' => 1, 'first_post_id' => 2, 'comment_count' => 1, 'is_sticky' => false, 'last_post_number' => 1], + ['id' => 3, 'title' => __CLASS__, 'created_at' => Carbon::now()->addMinutes(3), 'last_posted_at' => Carbon::now()->addMinute(), 'user_id' => 1, 'first_post_id' => 3, 'comment_count' => 1, 'is_sticky' => true, 'last_post_number' => 1], + ['id' => 4, 'title' => __CLASS__, 'created_at' => Carbon::now()->addMinutes(4), 'last_posted_at' => Carbon::now()->addMinutes(2), 'user_id' => 1, 'first_post_id' => 4, 'comment_count' => 1, 'is_sticky' => false, 'last_post_number' => 1], ], - 'groups' => [ + Post::class => [ + ['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '

Text

', 'number' => 1], + ['id' => 2, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '

Text

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

Text

', 'number' => 1], + ['id' => 4, 'discussion_id' => 4, 'user_id' => 1, 'type' => 'comment', 'content' => '

Text

', 'number' => 1], + ], + Group::class => [ ['id' => 5, 'name_singular' => 'Group', 'name_plural' => 'Groups', 'color' => 'blue'], ], 'group_user' => [ diff --git a/extensions/subscriptions/src/Filter/SubscriptionFilter.php b/extensions/subscriptions/src/Filter/SubscriptionFilter.php index 6e6f6d2f4..993949ebd 100644 --- a/extensions/subscriptions/src/Filter/SubscriptionFilter.php +++ b/extensions/subscriptions/src/Filter/SubscriptionFilter.php @@ -14,7 +14,7 @@ use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; use Flarum\User\User; -use Illuminate\Database\Query\Builder; +use Illuminate\Database\Eloquent\Builder; /** * @implements FilterInterface diff --git a/extensions/subscriptions/tests/integration/api/discussions/ReplyNotificationTest.php b/extensions/subscriptions/tests/integration/api/discussions/ReplyNotificationTest.php index 55e7476a5..81423546a 100644 --- a/extensions/subscriptions/tests/integration/api/discussions/ReplyNotificationTest.php +++ b/extensions/subscriptions/tests/integration/api/discussions/ReplyNotificationTest.php @@ -41,15 +41,15 @@ class ReplyNotificationTest extends TestCase ['id' => 33, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 33, 'comment_count' => 6, 'last_post_number' => 6, 'last_post_id' => 38], ], Post::class => [ - ['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 1], - ['id' => 2, 'discussion_id' => 2, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 1], + ['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(1)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 1], + ['id' => 2, 'discussion_id' => 2, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(2)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 1], - ['id' => 33, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 1], - ['id' => 34, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 2], - ['id' => 35, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 3], - ['id' => 36, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 4], - ['id' => 37, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 5], - ['id' => 38, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 6], + ['id' => 33, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(3)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 1], + ['id' => 34, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(4)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 2], + ['id' => 35, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(5)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 3], + ['id' => 36, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(6)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 4], + ['id' => 37, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(7)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 5], + ['id' => 38, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(8)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 6], ], 'discussion_user' => [ ['discussion_id' => 1, 'user_id' => 1, 'last_read_post_number' => 1, 'subscription' => 'follow'], diff --git a/extensions/subscriptions/tests/integration/api/discussions/SubscribeTest.php b/extensions/subscriptions/tests/integration/api/discussions/SubscribeTest.php index 21a243867..b592ed7e9 100644 --- a/extensions/subscriptions/tests/integration/api/discussions/SubscribeTest.php +++ b/extensions/subscriptions/tests/integration/api/discussions/SubscribeTest.php @@ -10,8 +10,11 @@ namespace Flarum\Subscriptions\Tests\integration\api\discussions; use Carbon\Carbon; +use Flarum\Discussion\Discussion; +use Flarum\Post\Post; use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\TestCase; +use Flarum\User\User; class SubscribeTest extends TestCase { @@ -24,18 +27,18 @@ class SubscribeTest extends TestCase $this->extension('flarum-subscriptions'); $this->prepareDatabase([ - 'users' => [ + User::class => [ $this->normalUser(), ['id' => 3, 'username' => 'acme', 'email' => 'acme@machine.local', 'is_email_confirmed' => 1, 'preferences' => json_encode(['flarum-subscriptions.notify_for_all_posts' => true])], ['id' => 4, 'username' => 'acme2', 'email' => 'acme2@machine.local', 'is_email_confirmed' => 1], ], - 'discussions' => [ + Discussion::class => [ ['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'last_post_number' => 1, 'last_post_id' => 1], ['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 2, 'comment_count' => 1, 'last_post_number' => 1, 'last_post_id' => 2], ['id' => 33, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 33, 'comment_count' => 6, 'last_post_number' => 6, 'last_post_id' => 38], ], - 'posts' => [ + Post::class => [ ['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 1], ['id' => 2, 'discussion_id' => 2, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 1], diff --git a/extensions/suspend/src/Query/SuspendedFilter.php b/extensions/suspend/src/Query/SuspendedFilter.php index ed9080066..e4bfbf3c4 100644 --- a/extensions/suspend/src/Query/SuspendedFilter.php +++ b/extensions/suspend/src/Query/SuspendedFilter.php @@ -15,7 +15,7 @@ use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\User\Guest; use Flarum\User\UserRepository; -use Illuminate\Database\Query\Builder; +use Illuminate\Database\Eloquent\Builder; /** * @implements FilterInterface diff --git a/extensions/tags/src/Search/Filter/TagFilter.php b/extensions/tags/src/Search/Filter/TagFilter.php index 5c82c6bfd..552072aaa 100644 --- a/extensions/tags/src/Search/Filter/TagFilter.php +++ b/extensions/tags/src/Search/Filter/TagFilter.php @@ -16,8 +16,9 @@ use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; use Flarum\Tags\Tag; use Flarum\User\User; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\ModelNotFoundException; -use Illuminate\Database\Query\Builder; +use Illuminate\Database\Query\Builder as QueryBuilder; /** * @implements FilterInterface @@ -53,7 +54,7 @@ class TagFilter implements FilterInterface $query->where(function (Builder $query) use ($slugs, $negate, $actor) { foreach ($slugs as $slug) { if ($slug === 'untagged') { - $query->whereIn('discussions.id', function (Builder $query) { + $query->whereIn('discussions.id', function (QueryBuilder $query) { $query->select('discussion_id') ->from('discussion_tag'); }, 'or', ! $negate); @@ -65,7 +66,7 @@ class TagFilter implements FilterInterface $id = null; } - $query->whereIn('discussions.id', function (Builder $query) use ($id) { + $query->whereIn('discussions.id', function (QueryBuilder $query) use ($id) { $query->select('discussion_id') ->from('discussion_tag') ->where('tag_id', $id); diff --git a/extensions/tags/src/TagState.php b/extensions/tags/src/TagState.php index c17c0f6b5..7ad483810 100644 --- a/extensions/tags/src/TagState.php +++ b/extensions/tags/src/TagState.php @@ -31,6 +31,8 @@ class TagState extends AbstractModel protected $casts = ['marked_as_read_at' => 'datetime']; + public $incrementing = false; + public function tag(): BelongsTo { return $this->belongsTo(Tag::class); diff --git a/framework/core/js/src/admin/AdminApplication.tsx b/framework/core/js/src/admin/AdminApplication.tsx index 474013041..77c0f10e2 100644 --- a/framework/core/js/src/admin/AdminApplication.tsx +++ b/framework/core/js/src/admin/AdminApplication.tsx @@ -32,11 +32,18 @@ export type Extension = { extra: { 'flarum-extension': { title: string; + 'database-support': undefined | string[]; }; }; require?: Record; }; +export enum DatabaseDriver { + MySQL = 'MySQL', + PostgreSQL = 'PostgreSQL', + SQLite = 'SQLite', +} + export interface AdminApplicationData extends ApplicationData { extensions: Record; settings: Record; @@ -48,6 +55,14 @@ export interface AdminApplicationData extends ApplicationData { maintenanceByConfig: boolean; safeModeExtensions?: string[] | null; safeModeExtensionsConfig?: string[] | null; + + dbDriver: DatabaseDriver; + dbVersion: string; + dbOptions: Record; + phpVersion: string; + queueDriver: string; + schedulerStatus: string; + sessionDriver: string; } export default class AdminApplication extends Application { diff --git a/framework/core/js/src/admin/components/AdvancedPage.tsx b/framework/core/js/src/admin/components/AdvancedPage.tsx index feca76e01..47d3eeed0 100644 --- a/framework/core/js/src/admin/components/AdvancedPage.tsx +++ b/framework/core/js/src/admin/components/AdvancedPage.tsx @@ -11,6 +11,7 @@ import { MaintenanceMode } from '../../common/Application'; import Button from '../../common/components/Button'; import classList from '../../common/utils/classList'; import ExtensionBisect from './ExtensionBisect'; +import { DatabaseDriver } from '../AdminApplication'; export default class AdvancedPage extends AdminPage { searchDriverOptions: Record> = {}; @@ -68,6 +69,10 @@ export default class AdvancedPage e items.add('maintenance', this.maintenance(), 90); + if (app.data.dbDriver === DatabaseDriver.PostgreSQL) { + items.add(DatabaseDriver.PostgreSQL, this.pgsqlSettings(), 80); + } + return items; } @@ -187,4 +192,19 @@ export default class AdvancedPage e ); } + + pgsqlSettings() { + return ( + +
+ {this.buildSettingComponent({ + type: 'select', + setting: 'pgsql_search_configuration', + options: app.data.dbOptions.search_configurations, + label: app.translator.trans('core.admin.advanced.pgsql.search_configuration'), + })} +
+
+ ); + } } diff --git a/framework/core/js/src/admin/components/ExtensionPage.tsx b/framework/core/js/src/admin/components/ExtensionPage.tsx index 9626b8cb2..abf616658 100644 --- a/framework/core/js/src/admin/components/ExtensionPage.tsx +++ b/framework/core/js/src/admin/components/ExtensionPage.tsx @@ -20,6 +20,7 @@ import Form from '../../common/components/Form'; import Icon from '../../common/components/Icon'; import { MaintenanceMode } from '../../common/Application'; import InfoTile from '../../common/components/InfoTile'; +import Alert from '../../common/components/Alert'; export interface ExtensionPageAttrs extends IPageAttrs { id: string; @@ -79,8 +80,19 @@ export default class ExtensionPage) { + const supportsDbDriver = + !this.extension.extra['flarum-extension']['database-support'] || + this.extension.extra['flarum-extension']['database-support'].map((driver) => driver.toLowerCase()).includes(app.data.dbDriver.toLowerCase()); + return this.isEnabled() ? ( -
{this.sections(vnode).toArray()}
+
+ {!supportsDbDriver && ( + + {app.translator.trans('core.admin.extension.database_driver_mismatch')} + + )} + {this.sections(vnode).toArray()} +
) : (

{app.translator.trans('core.admin.extension.enable_to_see')}

@@ -187,7 +199,6 @@ export default class ExtensionPage @@ -225,6 +236,27 @@ export default class ExtensionPage { + return ( + { + mysql: 'MySQL', + sqlite: 'SQLite', + pgsql: 'PostgreSQL', + }[database] || database + ); + }); + + items.add( + 'database-support', + + + {supportedDatabases.join(', ')} + + ); + } + const extension = this.extension; items.add( 'readme', diff --git a/framework/core/js/src/common/Application.tsx b/framework/core/js/src/common/Application.tsx index 2e362e969..1389583b4 100644 --- a/framework/core/js/src/common/Application.tsx +++ b/framework/core/js/src/common/Application.tsx @@ -559,7 +559,11 @@ export default class Application { break; default: - if (this.requestWasCrossOrigin(error)) { + const code = error.response?.errors?.[0]?.code; + + if (code === 'db_error' && app.session.user?.isAdmin()) { + content = app.translator.trans('core.lib.error.db_error_message'); + } else if (this.requestWasCrossOrigin(error)) { content = app.translator.trans('core.lib.error.generic_cross_origin_message'); } else { content = app.translator.trans('core.lib.error.generic_message'); diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index 7f540a16f..48c35db0c 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -45,6 +45,8 @@ core: safe_mode_extensions: Extensions allowed to boot during safe mode safe_mode_extensions_override_help: "This setting is overridden by the safe_mode_extensions key in your config.php file. ({extensions})" section_label: Maintenance + pgsql: + search_configuration: Search configuration to use search: section_label: Search Drivers driver_heading: "Search Driver: {model}" @@ -211,6 +213,7 @@ core: extension: configure_scopes: Configure Scopes confirm_purge: Purging will remove all database entries and assets related to the extension. It will not uninstall the extension; that must be done via Composer. Are you sure you want to continue? + database_driver_mismatch: This extension does not support your configured database driver. disabled: Disabled enable_to_see: Enable the extension to view and change settings. enabled: Enabled @@ -698,6 +701,7 @@ core: # These translations are displayed as error messages. error: circular_dependencies_message: "Circular dependencies detected: {extensions}. Aborting. Please disable one of the extensions and try again." + db_error_message: "Database query failed. This may be caused by an incompatibility between an extension and your database driver." dependent_extensions_message: "Cannot disable {extension} until the following dependent extensions are disabled: {extensions}" extension_initialiation_failed_message: "{extension} failed to initialize, check the browser console for further information." generic_message: "Oops! Something went wrong. Please reload the page and try again." 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 412fe8d98..f82206431 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 @@ -32,11 +32,10 @@ return [ $table->unique(['discussion_id', 'number']); }); - $connection = $schema->getConnection(); - $prefix = $connection->getTablePrefix(); - - if ($connection->getDriverName() !== 'sqlite') { - $connection->statement('ALTER TABLE '.$prefix.'posts ADD FULLTEXT content (content)'); + if ($schema->getConnection()->getDriverName() !== 'sqlite') { + $schema->table('posts', function (Blueprint $table) { + $table->fullText('content'); + }); } }, 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 303f2027f..09b392e0c 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 @@ -26,11 +26,22 @@ return [ $table->integer('user_id')->unsigned()->change(); }); - // Use a separate schema instance because this column gets renamed - // in the previous one. - $schema->table('access_tokens', function (Blueprint $table) { - $table->dateTime('last_activity_at')->change(); - }); + if ($schema->getConnection()->getDriverName() === 'pgsql') { + $prefix = $schema->getConnection()->getTablePrefix(); + + // Changing an integer col to datetime is an unusual operation in PostgreSQL. + $schema->getConnection()->statement(<<table('access_tokens', function (Blueprint $table) { + $table->dateTime('last_activity_at')->change(); + }); + } }, 'down' => function (Builder $schema) { 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 e49130d5b..714a8482a 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 @@ -7,21 +7,23 @@ * LICENSE file that was distributed with this source code. */ +use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Builder; return [ 'up' => function (Builder $schema) { - $connection = $schema->getConnection(); - $prefix = $connection->getTablePrefix(); - - if ($connection->getDriverName() !== 'sqlite') { - $connection->statement('ALTER TABLE '.$prefix.'discussions ADD FULLTEXT title (title)'); + if ($schema->getConnection()->getDriverName() !== 'sqlite') { + $schema->table('discussions', function (Blueprint $table) { + $table->fullText('title'); + }); } }, 'down' => function (Builder $schema) { - $connection = $schema->getConnection(); - $prefix = $connection->getTablePrefix(); - $connection->statement('ALTER TABLE '.$prefix.'discussions DROP INDEX title'); + if ($schema->getConnection()->getDriverName() !== 'sqlite') { + $schema->table('discussions', function (Blueprint $table) { + $table->dropFullText('title'); + }); + } } ]; diff --git a/framework/core/migrations/2018_07_21_000000_seed_default_groups.php b/framework/core/migrations/2018_07_21_000000_seed_default_groups.php index 1ba4e83e1..5574153fa 100644 --- a/framework/core/migrations/2018_07_21_000000_seed_default_groups.php +++ b/framework/core/migrations/2018_07_21_000000_seed_default_groups.php @@ -28,6 +28,13 @@ return [ $db->table('groups')->insert(array_combine(['id', 'name_singular', 'name_plural', 'color', 'icon'], $group)); } + + // PgSQL doesn't auto-increment the sequence when inserting the IDs manually. + if ($db->getDriverName() === 'pgsql') { + $table = $db->getSchemaGrammar()->wrapTable('groups'); + $seq = $db->getSchemaGrammar()->wrapTable('groups_id_seq'); + $db->statement("SELECT setval('$seq', (SELECT MAX(id) FROM $table))"); + } }, 'down' => function (Builder $schema) { diff --git a/framework/core/migrations/2024_05_05_000001_convert_preferences_to_json_in_users.php b/framework/core/migrations/2024_05_05_000001_convert_preferences_to_json_in_users.php new file mode 100644 index 000000000..1129e4f03 --- /dev/null +++ b/framework/core/migrations/2024_05_05_000001_convert_preferences_to_json_in_users.php @@ -0,0 +1,37 @@ + function (Builder $schema) { + if ($schema->getConnection()->getDriverName() === 'pgsql') { + $users = $schema->getConnection()->getSchemaGrammar()->wrapTable('users'); + $preferences = $schema->getConnection()->getSchemaGrammar()->wrap('preferences'); + $schema->getConnection()->statement("ALTER TABLE $users ALTER COLUMN $preferences TYPE JSON USING preferences::TEXT::JSON"); + } else { + $schema->table('users', function (Blueprint $table) { + $table->json('preferences')->nullable()->change(); + }); + } + }, + + 'down' => function (Builder $schema) { + if ($schema->getConnection()->getDriverName() === 'pgsql') { + $users = $schema->getConnection()->getSchemaGrammar()->wrapTable('users'); + $preferences = $schema->getConnection()->getSchemaGrammar()->wrap('preferences'); + $schema->getConnection()->statement("ALTER TABLE $users ALTER COLUMN $preferences TYPE BYTEA USING preferences::TEXT::BYTEA"); + } else { + $schema->table('users', function (Blueprint $table) { + $table->binary('preferences')->nullable()->change(); + }); + } + } +]; diff --git a/framework/core/migrations/2024_05_07_000001_convert_data_to_json_in_notifications.php b/framework/core/migrations/2024_05_07_000001_convert_data_to_json_in_notifications.php new file mode 100644 index 000000000..c433d0c08 --- /dev/null +++ b/framework/core/migrations/2024_05_07_000001_convert_data_to_json_in_notifications.php @@ -0,0 +1,37 @@ + function (Builder $schema) { + if ($schema->getConnection()->getDriverName() === 'pgsql') { + $notifications = $schema->getConnection()->getSchemaGrammar()->wrapTable('notifications'); + $data = $schema->getConnection()->getSchemaGrammar()->wrap('data'); + $schema->getConnection()->statement("ALTER TABLE $notifications ALTER COLUMN $data TYPE JSON USING data::TEXT::JSON"); + } else { + $schema->table('notifications', function (Blueprint $table) { + $table->json('data')->nullable()->change(); + }); + } + }, + + 'down' => function (Builder $schema) { + if ($schema->getConnection()->getDriverName() === 'pgsql') { + $notifications = $schema->getConnection()->getSchemaGrammar()->wrapTable('notifications'); + $data = $schema->getConnection()->getSchemaGrammar()->wrap('data'); + $schema->getConnection()->statement("ALTER TABLE $notifications ALTER COLUMN $data TYPE BYTEA USING data::TEXT::BYTEA"); + } else { + $schema->table('notifications', function (Blueprint $table) { + $table->binary('data')->nullable()->change(); + }); + } + } +]; diff --git a/framework/core/migrations/pgsql-install.dump b/framework/core/migrations/pgsql-install.dump new file mode 100644 index 000000000..760c0b82d --- /dev/null +++ b/framework/core/migrations/pgsql-install.dump @@ -0,0 +1,1247 @@ +-- +-- PostgreSQL database dump +-- + +-- Dumped from database version 14.11 (Debian 14.11-1.pgdg120+2) +-- Dumped by pg_dump version 15.6 (Debian 15.6-0+deb12u1) + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Name: public; Type: SCHEMA; Schema: -; Owner: - +-- + +-- *not* creating schema, since initdb creates it + + +-- +-- Name: SCHEMA public; Type: COMMENT; Schema: -; Owner: - +-- + +COMMENT ON SCHEMA public IS ''; + + +SET default_tablespace = ''; + +-- +-- Name: db_prefix_access_tokens; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.db_prefix_access_tokens ( + token character varying(40) NOT NULL, + user_id integer NOT NULL, + last_activity_at timestamp(0) without time zone, + created_at timestamp(0) without time zone NOT NULL, + type character varying(100) NOT NULL, + id integer NOT NULL, + title character varying(150), + last_ip_address character varying(45), + last_user_agent character varying(255) +); + + +-- +-- Name: db_prefix_access_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.db_prefix_access_tokens_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: db_prefix_access_tokens_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.db_prefix_access_tokens_id_seq OWNED BY public.db_prefix_access_tokens.id; + + +-- +-- Name: db_prefix_api_keys; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.db_prefix_api_keys ( + key character varying(100) NOT NULL, + id integer NOT NULL, + allowed_ips character varying(255), + scopes character varying(255), + user_id integer, + created_at timestamp(0) without time zone NOT NULL, + last_activity_at timestamp(0) without time zone +); + + +-- +-- Name: db_prefix_api_keys_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.db_prefix_api_keys_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: db_prefix_api_keys_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.db_prefix_api_keys_id_seq OWNED BY public.db_prefix_api_keys.id; + + +-- +-- Name: db_prefix_discussion_user; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.db_prefix_discussion_user ( + user_id integer NOT NULL, + discussion_id integer NOT NULL, + last_read_at timestamp(0) without time zone, + last_read_post_number integer +); + + +-- +-- Name: db_prefix_discussions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.db_prefix_discussions ( + id integer NOT NULL, + title character varying(200) NOT NULL, + comment_count integer DEFAULT 1 NOT NULL, + participant_count integer DEFAULT 0 NOT NULL, + created_at timestamp(0) without time zone NOT NULL, + user_id integer, + first_post_id integer, + last_posted_at timestamp(0) without time zone, + last_posted_user_id integer, + last_post_id integer, + last_post_number integer, + hidden_at timestamp without time zone, + hidden_user_id integer, + slug character varying(255) NOT NULL, + is_private boolean DEFAULT false NOT NULL +); + + +-- +-- Name: db_prefix_discussions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.db_prefix_discussions_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: db_prefix_discussions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.db_prefix_discussions_id_seq OWNED BY public.db_prefix_discussions.id; + + +-- +-- Name: db_prefix_email_tokens; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.db_prefix_email_tokens ( + token character varying(100) NOT NULL, + email character varying(150) NOT NULL, + user_id integer NOT NULL, + created_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: db_prefix_group_permission; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.db_prefix_group_permission ( + group_id integer NOT NULL, + permission character varying(100) NOT NULL, + created_at timestamp(0) without time zone +); + + +-- +-- Name: db_prefix_group_user; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.db_prefix_group_user ( + user_id integer NOT NULL, + group_id integer NOT NULL, + created_at timestamp(0) without time zone +); + + +-- +-- Name: db_prefix_groups; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.db_prefix_groups ( + id integer NOT NULL, + name_singular character varying(100) NOT NULL, + name_plural character varying(100) NOT NULL, + color character varying(20), + icon character varying(100), + is_hidden boolean DEFAULT false NOT NULL, + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone +); + + +-- +-- Name: db_prefix_groups_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.db_prefix_groups_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: db_prefix_groups_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.db_prefix_groups_id_seq OWNED BY public.db_prefix_groups.id; + + +-- +-- Name: db_prefix_login_providers; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.db_prefix_login_providers ( + id integer NOT NULL, + user_id integer NOT NULL, + provider character varying(100) NOT NULL, + identifier character varying(100) NOT NULL, + created_at timestamp(0) without time zone, + last_login_at timestamp(0) without time zone +); + + +-- +-- Name: db_prefix_login_providers_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.db_prefix_login_providers_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: db_prefix_login_providers_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.db_prefix_login_providers_id_seq OWNED BY public.db_prefix_login_providers.id; + + +-- +-- Name: db_prefix_migrations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.db_prefix_migrations ( + id integer NOT NULL, + migration character varying(255) NOT NULL, + extension character varying(255) +); + + +-- +-- Name: db_prefix_migrations_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.db_prefix_migrations_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: db_prefix_migrations_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.db_prefix_migrations_id_seq OWNED BY public.db_prefix_migrations.id; + + +-- +-- Name: db_prefix_notifications; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.db_prefix_notifications ( + id integer NOT NULL, + user_id integer NOT NULL, + from_user_id integer, + type character varying(100) NOT NULL, + subject_id integer, + data json, + created_at timestamp(0) without time zone NOT NULL, + is_deleted boolean DEFAULT false NOT NULL, + read_at timestamp(0) without time zone +); + + +-- +-- Name: db_prefix_notifications_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.db_prefix_notifications_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: db_prefix_notifications_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.db_prefix_notifications_id_seq OWNED BY public.db_prefix_notifications.id; + + +-- +-- Name: db_prefix_password_tokens; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.db_prefix_password_tokens ( + token character varying(100) NOT NULL, + user_id integer NOT NULL, + created_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: db_prefix_post_user; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.db_prefix_post_user ( + post_id integer NOT NULL, + user_id integer NOT NULL +); + + +-- +-- Name: db_prefix_posts; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.db_prefix_posts ( + id integer NOT NULL, + discussion_id integer NOT NULL, + number integer, + created_at timestamp(0) without time zone NOT NULL, + user_id integer, + type character varying(100), + content text, + edited_at timestamp(0) without time zone, + edited_user_id integer, + hidden_at timestamp(0) without time zone, + hidden_user_id integer, + ip_address character varying(45), + is_private boolean DEFAULT false NOT NULL +); + + +-- +-- Name: COLUMN db_prefix_posts.content; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.db_prefix_posts.content IS ' '; + + +-- +-- Name: db_prefix_posts_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.db_prefix_posts_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: db_prefix_posts_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.db_prefix_posts_id_seq OWNED BY public.db_prefix_posts.id; + + +-- +-- Name: db_prefix_registration_tokens; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.db_prefix_registration_tokens ( + token character varying(100) NOT NULL, + payload text, + created_at timestamp(0) without time zone NOT NULL, + provider character varying(255) NOT NULL, + identifier character varying(255) NOT NULL, + user_attributes text +); + + +-- +-- Name: db_prefix_settings; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.db_prefix_settings ( + key character varying(100) NOT NULL, + value text +); + + +-- +-- Name: db_prefix_unsubscribe_tokens; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.db_prefix_unsubscribe_tokens ( + id bigint NOT NULL, + user_id integer NOT NULL, + email_type character varying(255) NOT NULL, + token character varying(100) NOT NULL, + unsubscribed_at timestamp(0) without time zone, + created_at timestamp(0) without time zone, + updated_at timestamp(0) without time zone +); + + +-- +-- Name: db_prefix_unsubscribe_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.db_prefix_unsubscribe_tokens_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: db_prefix_unsubscribe_tokens_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.db_prefix_unsubscribe_tokens_id_seq OWNED BY public.db_prefix_unsubscribe_tokens.id; + + +-- +-- Name: db_prefix_users; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.db_prefix_users ( + id integer NOT NULL, + username character varying(100) NOT NULL, + email character varying(150) NOT NULL, + is_email_confirmed boolean DEFAULT false NOT NULL, + password character varying(100) NOT NULL, + avatar_url character varying(100), + preferences json, + joined_at timestamp(0) without time zone, + last_seen_at timestamp(0) without time zone, + marked_all_as_read_at timestamp(0) without time zone, + read_notifications_at timestamp(0) without time zone, + discussion_count integer DEFAULT 0 NOT NULL, + comment_count integer DEFAULT 0 NOT NULL +); + + +-- +-- Name: db_prefix_users_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.db_prefix_users_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: db_prefix_users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.db_prefix_users_id_seq OWNED BY public.db_prefix_users.id; + + +-- +-- Name: db_prefix_access_tokens id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_access_tokens ALTER COLUMN id SET DEFAULT nextval('public.db_prefix_access_tokens_id_seq'::regclass); + + +-- +-- Name: db_prefix_api_keys id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_api_keys ALTER COLUMN id SET DEFAULT nextval('public.db_prefix_api_keys_id_seq'::regclass); + + +-- +-- Name: db_prefix_discussions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_discussions ALTER COLUMN id SET DEFAULT nextval('public.db_prefix_discussions_id_seq'::regclass); + + +-- +-- Name: db_prefix_groups id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_groups ALTER COLUMN id SET DEFAULT nextval('public.db_prefix_groups_id_seq'::regclass); + + +-- +-- Name: db_prefix_login_providers id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_login_providers ALTER COLUMN id SET DEFAULT nextval('public.db_prefix_login_providers_id_seq'::regclass); + + +-- +-- Name: db_prefix_migrations id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_migrations ALTER COLUMN id SET DEFAULT nextval('public.db_prefix_migrations_id_seq'::regclass); + + +-- +-- Name: db_prefix_notifications id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_notifications ALTER COLUMN id SET DEFAULT nextval('public.db_prefix_notifications_id_seq'::regclass); + + +-- +-- Name: db_prefix_posts id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_posts ALTER COLUMN id SET DEFAULT nextval('public.db_prefix_posts_id_seq'::regclass); + + +-- +-- Name: db_prefix_unsubscribe_tokens id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_unsubscribe_tokens ALTER COLUMN id SET DEFAULT nextval('public.db_prefix_unsubscribe_tokens_id_seq'::regclass); + + +-- +-- Name: db_prefix_users id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_users ALTER COLUMN id SET DEFAULT nextval('public.db_prefix_users_id_seq'::regclass); + + +-- +-- Name: db_prefix_access_tokens db_prefix_access_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_access_tokens + ADD CONSTRAINT db_prefix_access_tokens_pkey PRIMARY KEY (id); + + +-- +-- Name: db_prefix_access_tokens db_prefix_access_tokens_token_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_access_tokens + ADD CONSTRAINT db_prefix_access_tokens_token_unique UNIQUE (token); + + +-- +-- Name: db_prefix_api_keys db_prefix_api_keys_key_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_api_keys + ADD CONSTRAINT db_prefix_api_keys_key_unique UNIQUE (key); + + +-- +-- Name: db_prefix_api_keys db_prefix_api_keys_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_api_keys + ADD CONSTRAINT db_prefix_api_keys_pkey PRIMARY KEY (id); + + +-- +-- Name: db_prefix_registration_tokens db_prefix_auth_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_registration_tokens + ADD CONSTRAINT db_prefix_auth_tokens_pkey PRIMARY KEY (token); + + +-- +-- Name: db_prefix_settings db_prefix_config_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_settings + ADD CONSTRAINT db_prefix_config_pkey PRIMARY KEY (key); + + +-- +-- Name: db_prefix_discussions db_prefix_discussions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_discussions + ADD CONSTRAINT db_prefix_discussions_pkey PRIMARY KEY (id); + + +-- +-- Name: db_prefix_email_tokens db_prefix_email_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_email_tokens + ADD CONSTRAINT db_prefix_email_tokens_pkey PRIMARY KEY (token); + + +-- +-- Name: db_prefix_groups db_prefix_groups_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_groups + ADD CONSTRAINT db_prefix_groups_pkey PRIMARY KEY (id); + + +-- +-- Name: db_prefix_login_providers db_prefix_login_providers_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_login_providers + ADD CONSTRAINT db_prefix_login_providers_pkey PRIMARY KEY (id); + + +-- +-- Name: db_prefix_login_providers db_prefix_login_providers_provider_identifier_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_login_providers + ADD CONSTRAINT db_prefix_login_providers_provider_identifier_unique UNIQUE (provider, identifier); + + +-- +-- Name: db_prefix_migrations db_prefix_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_migrations + ADD CONSTRAINT db_prefix_migrations_pkey PRIMARY KEY (id); + + +-- +-- Name: db_prefix_notifications db_prefix_notifications_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_notifications + ADD CONSTRAINT db_prefix_notifications_pkey PRIMARY KEY (id); + + +-- +-- Name: db_prefix_password_tokens db_prefix_password_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_password_tokens + ADD CONSTRAINT db_prefix_password_tokens_pkey PRIMARY KEY (token); + + +-- +-- Name: db_prefix_group_permission db_prefix_permissions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_group_permission + ADD CONSTRAINT db_prefix_permissions_pkey PRIMARY KEY (group_id, permission); + + +-- +-- Name: db_prefix_post_user db_prefix_post_user_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_post_user + ADD CONSTRAINT db_prefix_post_user_pkey PRIMARY KEY (post_id, user_id); + + +-- +-- Name: db_prefix_posts db_prefix_posts_discussion_id_number_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_posts + ADD CONSTRAINT db_prefix_posts_discussion_id_number_unique UNIQUE (discussion_id, number); + + +-- +-- Name: db_prefix_posts db_prefix_posts_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_posts + ADD CONSTRAINT db_prefix_posts_pkey PRIMARY KEY (id); + + +-- +-- Name: db_prefix_unsubscribe_tokens db_prefix_unsubscribe_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_unsubscribe_tokens + ADD CONSTRAINT db_prefix_unsubscribe_tokens_pkey PRIMARY KEY (id); + + +-- +-- Name: db_prefix_unsubscribe_tokens db_prefix_unsubscribe_tokens_token_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_unsubscribe_tokens + ADD CONSTRAINT db_prefix_unsubscribe_tokens_token_unique UNIQUE (token); + + +-- +-- Name: db_prefix_discussion_user db_prefix_users_discussions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_discussion_user + ADD CONSTRAINT db_prefix_users_discussions_pkey PRIMARY KEY (user_id, discussion_id); + + +-- +-- Name: db_prefix_users db_prefix_users_email_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_users + ADD CONSTRAINT db_prefix_users_email_unique UNIQUE (email); + + +-- +-- Name: db_prefix_group_user db_prefix_users_groups_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_group_user + ADD CONSTRAINT db_prefix_users_groups_pkey PRIMARY KEY (user_id, group_id); + + +-- +-- Name: db_prefix_users db_prefix_users_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_users + ADD CONSTRAINT db_prefix_users_pkey PRIMARY KEY (id); + + +-- +-- Name: db_prefix_users db_prefix_users_username_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_users + ADD CONSTRAINT db_prefix_users_username_unique UNIQUE (username); + + +-- +-- Name: db_prefix_access_tokens_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_access_tokens_type_index ON public.db_prefix_access_tokens USING btree (type); + + +-- +-- Name: db_prefix_discussions_comment_count_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_discussions_comment_count_index ON public.db_prefix_discussions USING btree (comment_count); + + +-- +-- Name: db_prefix_discussions_created_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_discussions_created_at_index ON public.db_prefix_discussions USING btree (created_at); + + +-- +-- Name: db_prefix_discussions_hidden_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_discussions_hidden_at_index ON public.db_prefix_discussions USING btree (hidden_at); + + +-- +-- Name: db_prefix_discussions_last_posted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_discussions_last_posted_at_index ON public.db_prefix_discussions USING btree (last_posted_at); + + +-- +-- Name: db_prefix_discussions_last_posted_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_discussions_last_posted_user_id_index ON public.db_prefix_discussions USING btree (last_posted_user_id); + + +-- +-- Name: db_prefix_discussions_participant_count_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_discussions_participant_count_index ON public.db_prefix_discussions USING btree (participant_count); + + +-- +-- Name: db_prefix_discussions_title_fulltext; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_discussions_title_fulltext ON public.db_prefix_discussions USING gin (to_tsvector('english'::regconfig, (title)::text)); + + +-- +-- Name: db_prefix_discussions_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_discussions_user_id_index ON public.db_prefix_discussions USING btree (user_id); + + +-- +-- Name: db_prefix_notifications_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_notifications_user_id_index ON public.db_prefix_notifications USING btree (user_id); + + +-- +-- Name: db_prefix_posts_content_fulltext; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_posts_content_fulltext ON public.db_prefix_posts USING gin (to_tsvector('english'::regconfig, content)); + + +-- +-- Name: db_prefix_posts_discussion_id_created_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_posts_discussion_id_created_at_index ON public.db_prefix_posts USING btree (discussion_id, created_at); + + +-- +-- Name: db_prefix_posts_discussion_id_number_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_posts_discussion_id_number_index ON public.db_prefix_posts USING btree (discussion_id, number); + + +-- +-- Name: db_prefix_posts_type_created_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_posts_type_created_at_index ON public.db_prefix_posts USING btree (type, created_at); + + +-- +-- Name: db_prefix_posts_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_posts_type_index ON public.db_prefix_posts USING btree (type); + + +-- +-- Name: db_prefix_posts_user_id_created_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_posts_user_id_created_at_index ON public.db_prefix_posts USING btree (user_id, created_at); + + +-- +-- Name: db_prefix_unsubscribe_tokens_email_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_unsubscribe_tokens_email_type_index ON public.db_prefix_unsubscribe_tokens USING btree (email_type); + + +-- +-- Name: db_prefix_unsubscribe_tokens_token_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_unsubscribe_tokens_token_index ON public.db_prefix_unsubscribe_tokens USING btree (token); + + +-- +-- Name: db_prefix_unsubscribe_tokens_user_id_email_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_unsubscribe_tokens_user_id_email_type_index ON public.db_prefix_unsubscribe_tokens USING btree (user_id, email_type); + + +-- +-- Name: db_prefix_unsubscribe_tokens_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_unsubscribe_tokens_user_id_index ON public.db_prefix_unsubscribe_tokens USING btree (user_id); + + +-- +-- Name: db_prefix_users_comment_count_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_users_comment_count_index ON public.db_prefix_users USING btree (comment_count); + + +-- +-- Name: db_prefix_users_discussion_count_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_users_discussion_count_index ON public.db_prefix_users USING btree (discussion_count); + + +-- +-- Name: db_prefix_users_joined_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_users_joined_at_index ON public.db_prefix_users USING btree (joined_at); + + +-- +-- Name: db_prefix_users_last_seen_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX db_prefix_users_last_seen_at_index ON public.db_prefix_users USING btree (last_seen_at); + + +-- +-- Name: db_prefix_access_tokens db_prefix_access_tokens_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_access_tokens + ADD CONSTRAINT db_prefix_access_tokens_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.db_prefix_users(id) ON DELETE CASCADE; + + +-- +-- Name: db_prefix_api_keys db_prefix_api_keys_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_api_keys + ADD CONSTRAINT db_prefix_api_keys_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.db_prefix_users(id) ON DELETE CASCADE; + + +-- +-- Name: db_prefix_discussion_user db_prefix_discussion_user_discussion_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_discussion_user + ADD CONSTRAINT db_prefix_discussion_user_discussion_id_foreign FOREIGN KEY (discussion_id) REFERENCES public.db_prefix_discussions(id) ON DELETE CASCADE; + + +-- +-- Name: db_prefix_discussion_user db_prefix_discussion_user_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_discussion_user + ADD CONSTRAINT db_prefix_discussion_user_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.db_prefix_users(id) ON DELETE CASCADE; + + +-- +-- Name: db_prefix_discussions db_prefix_discussions_first_post_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_discussions + ADD CONSTRAINT db_prefix_discussions_first_post_id_foreign FOREIGN KEY (first_post_id) REFERENCES public.db_prefix_posts(id) ON DELETE SET NULL; + + +-- +-- Name: db_prefix_discussions db_prefix_discussions_hidden_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_discussions + ADD CONSTRAINT db_prefix_discussions_hidden_user_id_foreign FOREIGN KEY (hidden_user_id) REFERENCES public.db_prefix_users(id) ON DELETE SET NULL; + + +-- +-- Name: db_prefix_discussions db_prefix_discussions_last_post_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_discussions + ADD CONSTRAINT db_prefix_discussions_last_post_id_foreign FOREIGN KEY (last_post_id) REFERENCES public.db_prefix_posts(id) ON DELETE SET NULL; + + +-- +-- Name: db_prefix_discussions db_prefix_discussions_last_posted_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_discussions + ADD CONSTRAINT db_prefix_discussions_last_posted_user_id_foreign FOREIGN KEY (last_posted_user_id) REFERENCES public.db_prefix_users(id) ON DELETE SET NULL; + + +-- +-- Name: db_prefix_discussions db_prefix_discussions_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_discussions + ADD CONSTRAINT db_prefix_discussions_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.db_prefix_users(id) ON DELETE SET NULL; + + +-- +-- Name: db_prefix_email_tokens db_prefix_email_tokens_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_email_tokens + ADD CONSTRAINT db_prefix_email_tokens_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.db_prefix_users(id) ON DELETE CASCADE; + + +-- +-- Name: db_prefix_group_permission db_prefix_group_permission_group_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_group_permission + ADD CONSTRAINT db_prefix_group_permission_group_id_foreign FOREIGN KEY (group_id) REFERENCES public.db_prefix_groups(id) ON DELETE CASCADE; + + +-- +-- Name: db_prefix_group_user db_prefix_group_user_group_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_group_user + ADD CONSTRAINT db_prefix_group_user_group_id_foreign FOREIGN KEY (group_id) REFERENCES public.db_prefix_groups(id) ON DELETE CASCADE; + + +-- +-- Name: db_prefix_group_user db_prefix_group_user_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_group_user + ADD CONSTRAINT db_prefix_group_user_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.db_prefix_users(id) ON DELETE CASCADE; + + +-- +-- Name: db_prefix_login_providers db_prefix_login_providers_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_login_providers + ADD CONSTRAINT db_prefix_login_providers_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.db_prefix_users(id) ON DELETE CASCADE; + + +-- +-- Name: db_prefix_notifications db_prefix_notifications_from_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_notifications + ADD CONSTRAINT db_prefix_notifications_from_user_id_foreign FOREIGN KEY (from_user_id) REFERENCES public.db_prefix_users(id) ON DELETE SET NULL; + + +-- +-- Name: db_prefix_notifications db_prefix_notifications_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_notifications + ADD CONSTRAINT db_prefix_notifications_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.db_prefix_users(id) ON DELETE CASCADE; + + +-- +-- Name: db_prefix_password_tokens db_prefix_password_tokens_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_password_tokens + ADD CONSTRAINT db_prefix_password_tokens_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.db_prefix_users(id) ON DELETE CASCADE; + + +-- +-- Name: db_prefix_post_user db_prefix_post_user_post_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_post_user + ADD CONSTRAINT db_prefix_post_user_post_id_foreign FOREIGN KEY (post_id) REFERENCES public.db_prefix_posts(id) ON DELETE CASCADE; + + +-- +-- Name: db_prefix_post_user db_prefix_post_user_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_post_user + ADD CONSTRAINT db_prefix_post_user_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.db_prefix_users(id) ON DELETE CASCADE; + + +-- +-- Name: db_prefix_posts db_prefix_posts_discussion_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_posts + ADD CONSTRAINT db_prefix_posts_discussion_id_foreign FOREIGN KEY (discussion_id) REFERENCES public.db_prefix_discussions(id) ON DELETE CASCADE; + + +-- +-- Name: db_prefix_posts db_prefix_posts_edited_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_posts + ADD CONSTRAINT db_prefix_posts_edited_user_id_foreign FOREIGN KEY (edited_user_id) REFERENCES public.db_prefix_users(id) ON DELETE SET NULL; + + +-- +-- Name: db_prefix_posts db_prefix_posts_hidden_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_posts + ADD CONSTRAINT db_prefix_posts_hidden_user_id_foreign FOREIGN KEY (hidden_user_id) REFERENCES public.db_prefix_users(id) ON DELETE SET NULL; + + +-- +-- Name: db_prefix_posts db_prefix_posts_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_posts + ADD CONSTRAINT db_prefix_posts_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.db_prefix_users(id) ON DELETE SET NULL; + + +-- +-- Name: db_prefix_unsubscribe_tokens db_prefix_unsubscribe_tokens_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.db_prefix_unsubscribe_tokens + ADD CONSTRAINT db_prefix_unsubscribe_tokens_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.db_prefix_users(id) ON DELETE CASCADE; + + +-- +-- PostgreSQL database dump complete +-- + +-- +-- PostgreSQL database dump +-- + +-- Dumped from database version 14.11 (Debian 14.11-1.pgdg120+2) +-- Dumped by pg_dump version 15.6 (Debian 15.6-0+deb12u1) + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Data for Name: db_prefix_migrations; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.db_prefix_migrations VALUES (1,'2015_02_24_000000_create_access_tokens_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (2,'2015_02_24_000000_create_api_keys_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (3,'2015_02_24_000000_create_config_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (4,'2015_02_24_000000_create_discussions_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (5,'2015_02_24_000000_create_email_tokens_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (6,'2015_02_24_000000_create_groups_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (7,'2015_02_24_000000_create_notifications_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (8,'2015_02_24_000000_create_password_tokens_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (9,'2015_02_24_000000_create_permissions_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (10,'2015_02_24_000000_create_posts_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (11,'2015_02_24_000000_create_users_discussions_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (12,'2015_02_24_000000_create_users_groups_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (13,'2015_02_24_000000_create_users_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (14,'2015_09_15_000000_create_auth_tokens_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (15,'2015_09_20_224327_add_hide_to_discussions',NULL); +INSERT INTO public.db_prefix_migrations VALUES (16,'2015_09_22_030432_rename_notification_read_time',NULL); +INSERT INTO public.db_prefix_migrations VALUES (17,'2015_10_07_130531_rename_config_to_settings',NULL); +INSERT INTO public.db_prefix_migrations VALUES (18,'2015_10_24_194000_add_ip_address_to_posts',NULL); +INSERT INTO public.db_prefix_migrations VALUES (19,'2015_12_05_042721_change_access_tokens_columns',NULL); +INSERT INTO public.db_prefix_migrations VALUES (20,'2015_12_17_194247_change_settings_value_column_to_text',NULL); +INSERT INTO public.db_prefix_migrations VALUES (21,'2016_02_04_095452_add_slug_to_discussions',NULL); +INSERT INTO public.db_prefix_migrations VALUES (22,'2017_04_07_114138_add_is_private_to_discussions',NULL); +INSERT INTO public.db_prefix_migrations VALUES (23,'2017_04_07_114138_add_is_private_to_posts',NULL); +INSERT INTO public.db_prefix_migrations VALUES (24,'2018_01_11_093900_change_access_tokens_columns',NULL); +INSERT INTO public.db_prefix_migrations VALUES (25,'2018_01_11_094000_change_access_tokens_add_foreign_keys',NULL); +INSERT INTO public.db_prefix_migrations VALUES (26,'2018_01_11_095000_change_api_keys_columns',NULL); +INSERT INTO public.db_prefix_migrations VALUES (27,'2018_01_11_101800_rename_auth_tokens_to_registration_tokens',NULL); +INSERT INTO public.db_prefix_migrations VALUES (28,'2018_01_11_102000_change_registration_tokens_rename_id_to_token',NULL); +INSERT INTO public.db_prefix_migrations VALUES (29,'2018_01_11_102100_change_registration_tokens_created_at_to_datetime',NULL); +INSERT INTO public.db_prefix_migrations VALUES (30,'2018_01_11_120604_change_posts_table_to_innodb',NULL); +INSERT INTO public.db_prefix_migrations VALUES (31,'2018_01_11_155200_change_discussions_rename_columns',NULL); +INSERT INTO public.db_prefix_migrations VALUES (32,'2018_01_11_155300_change_discussions_add_foreign_keys',NULL); +INSERT INTO public.db_prefix_migrations VALUES (33,'2018_01_15_071700_rename_users_discussions_to_discussion_user',NULL); +INSERT INTO public.db_prefix_migrations VALUES (34,'2018_01_15_071800_change_discussion_user_rename_columns',NULL); +INSERT INTO public.db_prefix_migrations VALUES (35,'2018_01_15_071900_change_discussion_user_add_foreign_keys',NULL); +INSERT INTO public.db_prefix_migrations VALUES (36,'2018_01_15_072600_change_email_tokens_rename_id_to_token',NULL); +INSERT INTO public.db_prefix_migrations VALUES (37,'2018_01_15_072700_change_email_tokens_add_foreign_keys',NULL); +INSERT INTO public.db_prefix_migrations VALUES (38,'2018_01_15_072800_change_email_tokens_created_at_to_datetime',NULL); +INSERT INTO public.db_prefix_migrations VALUES (39,'2018_01_18_130400_rename_permissions_to_group_permission',NULL); +INSERT INTO public.db_prefix_migrations VALUES (40,'2018_01_18_130500_change_group_permission_add_foreign_keys',NULL); +INSERT INTO public.db_prefix_migrations VALUES (41,'2018_01_18_130600_rename_users_groups_to_group_user',NULL); +INSERT INTO public.db_prefix_migrations VALUES (42,'2018_01_18_130700_change_group_user_add_foreign_keys',NULL); +INSERT INTO public.db_prefix_migrations VALUES (43,'2018_01_18_133000_change_notifications_columns',NULL); +INSERT INTO public.db_prefix_migrations VALUES (44,'2018_01_18_133100_change_notifications_add_foreign_keys',NULL); +INSERT INTO public.db_prefix_migrations VALUES (45,'2018_01_18_134400_change_password_tokens_rename_id_to_token',NULL); +INSERT INTO public.db_prefix_migrations VALUES (46,'2018_01_18_134500_change_password_tokens_add_foreign_keys',NULL); +INSERT INTO public.db_prefix_migrations VALUES (47,'2018_01_18_134600_change_password_tokens_created_at_to_datetime',NULL); +INSERT INTO public.db_prefix_migrations VALUES (48,'2018_01_18_135000_change_posts_rename_columns',NULL); +INSERT INTO public.db_prefix_migrations VALUES (49,'2018_01_18_135100_change_posts_add_foreign_keys',NULL); +INSERT INTO public.db_prefix_migrations VALUES (50,'2018_01_30_112238_add_fulltext_index_to_discussions_title',NULL); +INSERT INTO public.db_prefix_migrations VALUES (51,'2018_01_30_220100_create_post_user_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (52,'2018_01_30_222900_change_users_rename_columns',NULL); +INSERT INTO public.db_prefix_migrations VALUES (55,'2018_09_15_041340_add_users_indicies',NULL); +INSERT INTO public.db_prefix_migrations VALUES (56,'2018_09_15_041828_add_discussions_indicies',NULL); +INSERT INTO public.db_prefix_migrations VALUES (57,'2018_09_15_043337_add_notifications_indices',NULL); +INSERT INTO public.db_prefix_migrations VALUES (58,'2018_09_15_043621_add_posts_indices',NULL); +INSERT INTO public.db_prefix_migrations VALUES (59,'2018_09_22_004100_change_registration_tokens_columns',NULL); +INSERT INTO public.db_prefix_migrations VALUES (60,'2018_09_22_004200_create_login_providers_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (61,'2018_10_08_144700_add_shim_prefix_to_group_icons',NULL); +INSERT INTO public.db_prefix_migrations VALUES (62,'2019_10_12_195349_change_posts_add_discussion_foreign_key',NULL); +INSERT INTO public.db_prefix_migrations VALUES (63,'2020_03_19_134512_change_discussions_default_comment_count',NULL); +INSERT INTO public.db_prefix_migrations VALUES (64,'2020_04_21_130500_change_permission_groups_add_is_hidden',NULL); +INSERT INTO public.db_prefix_migrations VALUES (65,'2021_03_02_040000_change_access_tokens_add_type',NULL); +INSERT INTO public.db_prefix_migrations VALUES (66,'2021_03_02_040500_change_access_tokens_add_id',NULL); +INSERT INTO public.db_prefix_migrations VALUES (67,'2021_03_02_041000_change_access_tokens_add_title_ip_agent',NULL); +INSERT INTO public.db_prefix_migrations VALUES (68,'2021_04_18_040500_change_migrations_add_id_primary_key',NULL); +INSERT INTO public.db_prefix_migrations VALUES (69,'2021_04_18_145100_change_posts_content_column_to_mediumtext',NULL); +INSERT INTO public.db_prefix_migrations VALUES (70,'2021_05_10_000000_rename_permissions',NULL); +INSERT INTO public.db_prefix_migrations VALUES (71,'2022_05_20_000000_add_timestamps_to_groups_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (72,'2022_05_20_000001_add_created_at_to_group_user_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (73,'2022_05_20_000002_add_created_at_to_group_permission_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (74,'2022_07_14_000000_add_type_index_to_posts',NULL); +INSERT INTO public.db_prefix_migrations VALUES (75,'2022_07_14_000001_add_type_created_at_composite_index_to_posts',NULL); +INSERT INTO public.db_prefix_migrations VALUES (76,'2022_08_06_000000_change_access_tokens_last_activity_at_to_nullable',NULL); +INSERT INTO public.db_prefix_migrations VALUES (77,'2023_08_19_000000_create_unsubscribe_tokens_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (78,'2023_10_23_000000_drop_post_number_index_column_from_discussions_table',NULL); +INSERT INTO public.db_prefix_migrations VALUES (79,'2024_05_05_000000_add_sqlite_keys',NULL); +INSERT INTO public.db_prefix_migrations VALUES (80,'2024_05_05_000001_convert_preferences_to_json_in_users',NULL); +INSERT INTO public.db_prefix_migrations VALUES (81,'2024_05_07_000001_convert_data_to_json_in_notifications.php',NULL); + +-- +-- Name: db_prefix_migrations_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.db_prefix_migrations_id_seq', 81, true); + + +-- +-- PostgreSQL database dump complete +-- diff --git a/framework/core/src/Admin/Content/AdminPayload.php b/framework/core/src/Admin/Content/AdminPayload.php index dea5431a1..134340a55 100644 --- a/framework/core/src/Admin/Content/AdminPayload.php +++ b/framework/core/src/Admin/Content/AdminPayload.php @@ -62,6 +62,7 @@ class AdminPayload $document->payload['phpVersion'] = $this->appInfo->identifyPHPVersion(); $document->payload['dbDriver'] = $this->appInfo->identifyDatabaseDriver(); $document->payload['dbVersion'] = $this->appInfo->identifyDatabaseVersion(); + $document->payload['dbOptions'] = $this->appInfo->identifyDatabaseOptions(); $document->payload['debugEnabled'] = Arr::get($this->config, 'debug'); if ($this->appInfo->scheduledTasksRegistered()) { diff --git a/framework/core/src/Api/Resource/UserResource.php b/framework/core/src/Api/Resource/UserResource.php index 71746fdd2..8a5be2301 100644 --- a/framework/core/src/Api/Resource/UserResource.php +++ b/framework/core/src/Api/Resource/UserResource.php @@ -217,7 +217,11 @@ class UserResource extends AbstractDatabaseResource || $context->getActor()->can('editCredentials', $user); }) ->set(function (User $user, ?string $value) { - $user->exists && $user->changePassword($value); + if ($user->exists) { + $user->changePassword($value); + } else { + $user->password = $value; + } }), // Registration token. Schema\Str::make('token') diff --git a/framework/core/src/Database/DatabaseServiceProvider.php b/framework/core/src/Database/DatabaseServiceProvider.php index cb0516216..ce43aa623 100644 --- a/framework/core/src/Database/DatabaseServiceProvider.php +++ b/framework/core/src/Database/DatabaseServiceProvider.php @@ -18,7 +18,9 @@ use Illuminate\Contracts\Container\Container; use Illuminate\Database\Capsule\Manager; use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionResolverInterface; +use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Support\Str; class DatabaseServiceProvider extends AbstractServiceProvider @@ -28,6 +30,7 @@ class DatabaseServiceProvider extends AbstractServiceProvider public function register(): void { $this->registerEloquentFactory(); + $this->registerBuilderMacros(); $this->container->singleton(Manager::class, function (ContainerImplementation $container) { $manager = new Manager($container); @@ -78,6 +81,41 @@ class DatabaseServiceProvider extends AbstractServiceProvider }); } + protected function registerBuilderMacros(): void + { + $drivers = [ + 'mysql' => 'MySql', + 'pgsql' => 'PgSql', + 'sqlite' => 'Sqlite', + ]; + + foreach ([QueryBuilder::class, EloquentBuilder::class] as $builder) { + foreach ($drivers as $driver => $macro) { + $builder::macro('when'.$macro, function ($callback, $else) use ($driver) { + /** @var QueryBuilder|EloquentBuilder $this */ + if ($this->getConnection()->getDriverName() === $driver) { + $callback($this); + } else { + $else($this); + } + + return $this; + }); + + $builder::macro('unless'.$macro, function ($callback, $else) use ($driver) { + /** @var QueryBuilder|EloquentBuilder $this */ + if ($this->getConnection()->getDriverName() !== $driver) { + $callback($this); + } else { + $else($this); + } + + return $this; + }); + } + } + } + protected function registerEloquentFactory(): void { $this->app->singleton(FakerGenerator::class, function ($app, $parameters) { diff --git a/framework/core/src/Database/Migrator.php b/framework/core/src/Database/Migrator.php index 37be1e525..2be94ab29 100644 --- a/framework/core/src/Database/Migrator.php +++ b/framework/core/src/Database/Migrator.php @@ -69,8 +69,38 @@ class Migrator // Once we have the array of migrations, we will spin through them and run the // migrations "up" so the changes are made to the databases. We'll then log // that the migration was run so we don't repeat it next time we execute. - foreach ($migrations as $file) { - $this->runUp($path, $file, $extension); + $this->runUpMigrations($migrations, $path, $extension); + } + + protected function runUpMigrations(array $migrations, string $path, ?Extension $extension = null): void + { + $process = function () use ($migrations, $path, $extension) { + foreach ($migrations as $migration) { + $this->runUp($path, $migration, $extension); + } + }; + + // PgSQL allows DDL statements in transactions. + if ($this->connection->getDriverName() === 'pgsql') { + $this->connection->transaction($process); + } else { + $process(); + } + } + + protected function runDownMigrations(array $migrations, string $path, ?Extension $extension = null): void + { + $process = function () use ($migrations, $path, $extension) { + foreach ($migrations as $migration) { + $this->runDown($path, $migration, $extension); + } + }; + + // PgSQL allows DDL statements in transactions. + if ($this->connection->getDriverName() === 'pgsql') { + $this->connection->transaction($process); + } else { + $process(); } } @@ -103,9 +133,7 @@ class Migrator if ($count === 0) { $this->note('Nothing to rollback.'); } else { - foreach ($migrations as $migration) { - $this->runDown($path, $migration, $extension); - } + $this->runDownMigrations($migrations, $path, $extension); } return $count; @@ -221,9 +249,11 @@ class Migrator $dump = file_get_contents($schemaPath); + $dumpWithoutComments = preg_replace('/^--.*$/m', '', $dump); + $this->connection->getSchemaBuilder()->disableForeignKeyConstraints(); - foreach (explode(';', $dump) as $statement) { + foreach (explode(';', $dumpWithoutComments) as $statement) { $statement = trim($statement); if (empty($statement) || str_starts_with($statement, '/*')) { @@ -238,6 +268,10 @@ class Migrator $this->connection->statement($statement); } + if ($driver === 'pgsql') { + $this->connection->statement('SELECT pg_catalog.set_config(\'search_path\', \'public\', false)'); + } + $this->connection->getSchemaBuilder()->enableForeignKeyConstraints(); $runTime = number_format((microtime(true) - $startTime) * 1000, 2); diff --git a/framework/core/src/Discussion/Discussion.php b/framework/core/src/Discussion/Discussion.php index 619c3dabb..a74fdd8c8 100644 --- a/framework/core/src/Discussion/Discussion.php +++ b/framework/core/src/Discussion/Discussion.php @@ -183,7 +183,7 @@ class Discussion extends AbstractModel public function refreshLastPost(): static { - if ($lastPost = $this->comments()->latest()->first()) { + if ($lastPost = $this->comments()->latest()->latest('id')->first()) { /** @var Post $lastPost */ $this->setLastPost($lastPost); } diff --git a/framework/core/src/Discussion/Search/Filter/AuthorFilter.php b/framework/core/src/Discussion/Search/Filter/AuthorFilter.php index ef5c44dc7..138cbc975 100644 --- a/framework/core/src/Discussion/Search/Filter/AuthorFilter.php +++ b/framework/core/src/Discussion/Search/Filter/AuthorFilter.php @@ -14,7 +14,7 @@ use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; use Flarum\User\UserRepository; -use Illuminate\Database\Query\Builder; +use Illuminate\Database\Eloquent\Builder; /** * @implements FilterInterface diff --git a/framework/core/src/Discussion/Search/Filter/CreatedFilter.php b/framework/core/src/Discussion/Search/Filter/CreatedFilter.php index c66bac114..bd93a2940 100644 --- a/framework/core/src/Discussion/Search/Filter/CreatedFilter.php +++ b/framework/core/src/Discussion/Search/Filter/CreatedFilter.php @@ -13,7 +13,7 @@ use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; -use Illuminate\Database\Query\Builder; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Arr; /** @@ -40,7 +40,7 @@ class CreatedFilter implements FilterInterface $this->constrain($state->getQuery(), $from, $to, $negate); } - public function constrain(Builder $query, ?string $from, ?string $to, bool $negate): void + protected function constrain(Builder $query, ?string $from, ?string $to, bool $negate): void { // If we've just been provided with a single YYYY-MM-DD date, then find // discussions that were started on that exact date. But if we've been diff --git a/framework/core/src/Discussion/Search/Filter/HiddenFilter.php b/framework/core/src/Discussion/Search/Filter/HiddenFilter.php index e9e52cae8..37fca1560 100644 --- a/framework/core/src/Discussion/Search/Filter/HiddenFilter.php +++ b/framework/core/src/Discussion/Search/Filter/HiddenFilter.php @@ -12,7 +12,7 @@ namespace Flarum\Discussion\Search\Filter; use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; -use Illuminate\Database\Query\Builder; +use Illuminate\Database\Eloquent\Builder; /** * @implements FilterInterface diff --git a/framework/core/src/Discussion/Search/Filter/UnreadFilter.php b/framework/core/src/Discussion/Search/Filter/UnreadFilter.php index 6c12ac635..945c65d41 100644 --- a/framework/core/src/Discussion/Search/Filter/UnreadFilter.php +++ b/framework/core/src/Discussion/Search/Filter/UnreadFilter.php @@ -14,7 +14,7 @@ use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\User\User; -use Illuminate\Database\Query\Builder; +use Illuminate\Database\Eloquent\Builder; /** * @implements FilterInterface @@ -41,11 +41,15 @@ class UnreadFilter implements FilterInterface if ($actor->exists) { $readIds = $this->discussions->getReadIdsQuery($actor); - $query->where(function ($query) use ($readIds, $negate, $actor) { + $query->where(function (Builder $query) use ($readIds, $negate, $actor) { if (! $negate) { - $query->whereNotIn('id', $readIds)->where('last_posted_at', '>', $actor->marked_all_as_read_at ?: 0); + $query->whereNotIn('id', $readIds)->when($actor->marked_all_as_read_at, function (Builder $query) use ($actor) { + $query->where('last_posted_at', '>', $actor->marked_all_as_read_at); + }); } else { - $query->whereIn('id', $readIds)->orWhere('last_posted_at', '<=', $actor->marked_all_as_read_at ?: 0); + $query->whereIn('id', $readIds)->when($actor->marked_all_as_read_at, function (Builder $query) use ($actor) { + $query->orWhere('last_posted_at', '<=', $actor->marked_all_as_read_at); + }); } }); } diff --git a/framework/core/src/Discussion/Search/FulltextFilter.php b/framework/core/src/Discussion/Search/FulltextFilter.php index ada06a184..44d2489c5 100644 --- a/framework/core/src/Discussion/Search/FulltextFilter.php +++ b/framework/core/src/Discussion/Search/FulltextFilter.php @@ -14,37 +14,57 @@ use Flarum\Post\Post; use Flarum\Search\AbstractFulltextFilter; use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\SearchState; -use Illuminate\Database\Query\Builder; +use Flarum\Settings\SettingsRepositoryInterface; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Database\Query\Expression; +use RuntimeException; /** * @extends AbstractFulltextFilter */ class FulltextFilter extends AbstractFulltextFilter { + public function __construct( + protected SettingsRepositoryInterface $settings + ) { + } + public function search(SearchState $state, string $value): void + { + match ($state->getQuery()->getConnection()->getDriverName()) { + 'mysql' => $this->mysql($state, $value), + 'pgsql' => $this->pgsql($state, $value), + 'sqlite' => $this->sqlite($state, $value), + default => throw new RuntimeException('Unsupported database driver: '.$state->getQuery()->getConnection()->getDriverName()), + }; + } + + protected function sqlite(DatabaseSearchState $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() - ); - }); - }); + $query->where(function (Builder $query) use ($state, $value) { + $query->where('discussions.title', 'like', "%$value%") + ->orWhereExists(function (QueryBuilder $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; - } + protected function mysql(DatabaseSearchState $state, string $value): void + { + /** @var Builder $query */ + $query = $state->getQuery(); // Replace all non-word characters with spaces. // We do this to prevent MySQL fulltext search boolean mode from taking @@ -53,10 +73,15 @@ class FulltextFilter extends AbstractFulltextFilter $grammar = $query->getGrammar(); + $match = 'MATCH('.$grammar->wrap('posts.content').') AGAINST (?)'; + $matchBooleanMode = 'MATCH('.$grammar->wrap('posts.content').') AGAINST (? IN BOOLEAN MODE)'; + $matchTitle = 'MATCH('.$grammar->wrap('discussions.title').') AGAINST (?)'; + $mostRelevantPostId = 'SUBSTRING_INDEX(GROUP_CONCAT('.$grammar->wrap('posts.id').' ORDER BY '.$match.' DESC, '.$grammar->wrap('posts.number').'), \',\', 1) as most_relevant_post_id'; + $discussionSubquery = Discussion::select('id') ->selectRaw('NULL as score') ->selectRaw('first_post_id as most_relevant_post_id') - ->whereRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (? IN BOOLEAN MODE)', [$value]); + ->whereRaw($matchTitle, [$value]); // Construct a subquery to fetch discussions which contain relevant // posts. Retrieve the collective relevance of each discussion's posts, @@ -64,10 +89,10 @@ class FulltextFilter extends AbstractFulltextFilter // the ID of the most relevant post. $subquery = Post::whereVisibleTo($state->getActor()) ->select('posts.discussion_id') - ->selectRaw('SUM(MATCH('.$grammar->wrap('posts.content').') AGAINST (?)) as score', [$value]) - ->selectRaw('SUBSTRING_INDEX(GROUP_CONCAT('.$grammar->wrap('posts.id').' ORDER BY MATCH('.$grammar->wrap('posts.content').') AGAINST (?) DESC, '.$grammar->wrap('posts.number').'), \',\', 1) as most_relevant_post_id', [$value]) + ->selectRaw("SUM($match) as score", [$value]) + ->selectRaw($mostRelevantPostId, [$value]) ->where('posts.type', 'comment') - ->whereRaw('MATCH('.$grammar->wrap('posts.content').') AGAINST (? IN BOOLEAN MODE)', [$value]) + ->whereRaw($matchBooleanMode, [$value]) ->groupBy('posts.discussion_id') ->union($discussionSubquery); @@ -84,9 +109,71 @@ class FulltextFilter extends AbstractFulltextFilter ->groupBy('discussions.id') ->addBinding($subquery->getBindings(), 'join'); - $state->setDefaultSort(function (Builder $query) use ($grammar, $value) { - $query->orderByRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (?) desc', [$value]); + $state->setDefaultSort(function (Builder $query) use ($value, $matchTitle) { + $query->orderByRaw("$matchTitle desc", [$value]); $query->orderBy('posts_ft.score', 'desc'); }); } + + protected function pgsql(DatabaseSearchState $state, string $value): void + { + $searchConfig = $this->settings->get('pgsql_search_configuration'); + + /** @var Builder $query */ + $query = $state->getQuery(); + + $grammar = $query->getGrammar(); + + $matchCondition = "to_tsvector('$searchConfig', ".$grammar->wrap('posts.content').") @@ plainto_tsquery('$searchConfig', ?)"; + $matchScore = "ts_rank(to_tsvector('$searchConfig', ".$grammar->wrap('posts.content')."), plainto_tsquery('$searchConfig', ?))"; + $matchTitleCondition = "to_tsvector('$searchConfig', ".$grammar->wrap('discussions.title').") @@ plainto_tsquery('$searchConfig', ?)"; + $matchTitleScore = "ts_rank(to_tsvector('$searchConfig', ".$grammar->wrap('discussions.title')."), plainto_tsquery('$searchConfig', ?))"; + $mostRelevantPostId = 'CAST(SPLIT_PART(STRING_AGG(CAST('.$grammar->wrap('posts.id')." AS VARCHAR), ',' ORDER BY ".$matchScore.' DESC, '.$grammar->wrap('posts.number')."), ',', 1) AS INTEGER) as most_relevant_post_id"; + + $discussionSubquery = Discussion::select('id') + ->selectRaw('NULL as score') + ->selectRaw('first_post_id as most_relevant_post_id') + ->whereRaw($matchTitleCondition, [$value]); + + // Construct a subquery to fetch discussions which contain relevant + // posts. Retrieve the collective relevance of each discussion's posts, + // which we will use later in the order by clause, and also retrieve + // the ID of the most relevant post. + $subquery = Post::whereVisibleTo($state->getActor()) + ->select('posts.discussion_id') + ->selectRaw("SUM($matchScore) as score", [$value]) + ->selectRaw($mostRelevantPostId, [$value]) + ->where('posts.type', 'comment') + ->whereRaw($matchCondition, [$value]) + ->groupBy('posts.discussion_id') + ->union($discussionSubquery); + + // Join the subquery into the main search query and scope results to + // discussions that have a relevant title or that contain relevant posts. + $query + ->distinct('discussions.id') + ->addSelect('posts_ft.most_relevant_post_id') + ->addSelect('posts_ft.score') + ->join( + new Expression('('.$subquery->toSql().') '.$grammar->wrapTable('posts_ft')), + 'posts_ft.discussion_id', + '=', + 'discussions.id' + ) + ->addBinding($subquery->getBindings(), 'join') + ->orderBy('discussions.id'); + + $state->setQuery( + $query + ->getModel() + ->newQuery() + ->select('*') + ->fromSub($query, 'discussions') + ); + + $state->setDefaultSort(function (Builder $query) use ($value, $matchTitleScore) { + $query->orderByRaw("$matchTitleScore desc", [$value]); + $query->orderBy('discussions.score', 'desc'); + }); + } } diff --git a/framework/core/src/Discussion/UserState.php b/framework/core/src/Discussion/UserState.php index f777fed1e..7aca423ac 100644 --- a/framework/core/src/Discussion/UserState.php +++ b/framework/core/src/Discussion/UserState.php @@ -44,6 +44,8 @@ class UserState extends AbstractModel 'last_read_at' => 'datetime' ]; + public $incrementing = false; + /** * The attributes that are mass assignable. */ diff --git a/framework/core/src/Foundation/ApplicationInfoProvider.php b/framework/core/src/Foundation/ApplicationInfoProvider.php index 0f5aa2211..075bb3ff9 100644 --- a/framework/core/src/Foundation/ApplicationInfoProvider.php +++ b/framework/core/src/Foundation/ApplicationInfoProvider.php @@ -71,7 +71,7 @@ class ApplicationInfoProvider public function identifyDatabaseVersion(): string { return match ($this->config['database.driver']) { - 'mysql' => $this->db->selectOne('select version() as version')->version, + 'mysql', 'pgsql' => $this->db->selectOne('select version() as version')->version, 'sqlite' => $this->db->selectOne('select sqlite_version() as version')->version, default => 'Unknown', }; @@ -81,11 +81,26 @@ class ApplicationInfoProvider { return match ($this->config['database.driver']) { 'mysql' => 'MySQL', + 'pgsql' => 'PostgreSQL', 'sqlite' => 'SQLite', default => $this->config['database.driver'], }; } + public function identifyDatabaseOptions(): array + { + if ($this->config['database.driver'] === 'pgsql') { + return [ + 'search_configurations' => collect($this->db->select('SELECT * FROM pg_ts_config')) + ->pluck('cfgname') + ->mapWithKeys(fn (string $cfgname) => [$cfgname => $cfgname]) + ->toArray(), + ]; + } + + return []; + } + /** * Reports on the session driver in use based on three scenarios: * 1. If the configured session driver is valid and in use, it will be returned. diff --git a/framework/core/src/Foundation/ErrorHandling/ExceptionHandler/QueryExceptionHandler.php b/framework/core/src/Foundation/ErrorHandling/ExceptionHandler/QueryExceptionHandler.php new file mode 100644 index 000000000..340711044 --- /dev/null +++ b/framework/core/src/Foundation/ErrorHandling/ExceptionHandler/QueryExceptionHandler.php @@ -0,0 +1,26 @@ +withDetails([]); + } +} diff --git a/framework/core/src/Foundation/ErrorHandling/HandledError.php b/framework/core/src/Foundation/ErrorHandling/HandledError.php index 2c55cfb70..5b7fa7349 100644 --- a/framework/core/src/Foundation/ErrorHandling/HandledError.php +++ b/framework/core/src/Foundation/ErrorHandling/HandledError.php @@ -30,7 +30,8 @@ class HandledError public function __construct( private readonly Throwable $error, private readonly string $type, - private readonly int $statusCode + private readonly int $statusCode, + private bool $report = false ) { } @@ -58,7 +59,7 @@ class HandledError public function shouldBeReported(): bool { - return $this->type === 'unknown'; + return $this->type === 'unknown' || $this->report; } public function getDetails(): array diff --git a/framework/core/src/Foundation/ErrorServiceProvider.php b/framework/core/src/Foundation/ErrorServiceProvider.php index 93e25378a..8c368321f 100644 --- a/framework/core/src/Foundation/ErrorServiceProvider.php +++ b/framework/core/src/Foundation/ErrorServiceProvider.php @@ -13,6 +13,7 @@ use Flarum\Extension\Exception as ExtensionException; use Flarum\Foundation\ErrorHandling as Handling; use Flarum\Http\Exception\InvalidParameterException; use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Database\QueryException; use Illuminate\Validation\ValidationException as IlluminateValidationException; use Tobyz\JsonApiServer\Exception as TobyzJsonApiServerException; @@ -65,6 +66,7 @@ class ErrorServiceProvider extends AbstractServiceProvider ExtensionException\CircularDependenciesException::class => ExtensionException\CircularDependenciesExceptionHandler::class, ExtensionException\DependentExtensionsException::class => ExtensionException\DependentExtensionsExceptionHandler::class, ExtensionException\MissingDependenciesException::class => ExtensionException\MissingDependenciesExceptionHandler::class, + QueryException::class => Handling\ExceptionHandler\QueryExceptionHandler::class, TobyzJsonApiServerException\ErrorProvider::class => Handling\ExceptionHandler\JsonApiExceptionHandler::class, ]; }); diff --git a/framework/core/src/Group/Permission.php b/framework/core/src/Group/Permission.php index e6413c650..60be408ff 100644 --- a/framework/core/src/Group/Permission.php +++ b/framework/core/src/Group/Permission.php @@ -26,6 +26,8 @@ class Permission extends AbstractModel 'created_at' => 'datetime' ]; + public $incrementing = false; + public function group(): BelongsTo { return $this->belongsTo(Group::class); diff --git a/framework/core/src/Install/Console/UserDataProvider.php b/framework/core/src/Install/Console/UserDataProvider.php index 5ef3de990..7e58774d9 100644 --- a/framework/core/src/Install/Console/UserDataProvider.php +++ b/framework/core/src/Install/Console/UserDataProvider.php @@ -42,20 +42,31 @@ class UserDataProvider implements DataProviderInterface private function getDatabaseConfiguration(): DatabaseConfig { - $host = $this->ask('Database host (required):'); - $port = 3306; + $driver = $this->ask('Database driver (mysql, sqlite, pgsql) (Default: mysql):', 'mysql'); + $port = match ($driver) { + 'mysql' => 3306, + 'pgsql' => 5432, + default => 0, + }; - if (Str::contains($host, ':')) { - list($host, $port) = explode(':', $host, 2); + if (in_array($driver, ['mysql', 'pgsql'])) { + $host = $this->ask('Database host (required):'); + + if (Str::contains($host, ':')) { + list($host, $port) = explode(':', $host, 2); + } + + $user = $this->ask('Database user (required):'); + $password = $this->secret('Database password:'); } return new DatabaseConfig( - $this->ask('Database driver (mysql, sqlite) (Default: mysql):', 'mysql'), - $host, + $driver, + $host ?? null, intval($port), $this->ask('Database name (required):'), - $this->ask('Database user (required):'), - $this->secret('Database password:'), + $user ?? null, + $password ?? null, $this->ask('Prefix:') ); } diff --git a/framework/core/src/Install/Controller/InstallController.php b/framework/core/src/Install/Controller/InstallController.php index f066c794c..fee31cd69 100644 --- a/framework/core/src/Install/Controller/InstallController.php +++ b/framework/core/src/Install/Controller/InstallController.php @@ -76,20 +76,25 @@ class InstallController implements RequestHandlerInterface private function makeDatabaseConfig(array $input): DatabaseConfig { - $host = Arr::get($input, 'mysqlHost'); - $port = 3306; + $driver = Arr::get($input, 'dbDriver'); + $host = Arr::get($input, 'dbHost'); + $port = match ($driver) { + 'mysql' => 3306, + 'pgsql' => 5432, + default => 0, + }; if (Str::contains($host, ':')) { list($host, $port) = explode(':', $host, 2); } return new DatabaseConfig( - Arr::get($input, 'dbDriver'), + $driver, $host, intval($port), Arr::get($input, 'dbName'), - Arr::get($input, 'mysqlUsername'), - Arr::get($input, 'mysqlPassword'), + Arr::get($input, 'dbUsername'), + Arr::get($input, 'dbPassword'), Arr::get($input, 'tablePrefix') ); } diff --git a/framework/core/src/Install/DatabaseConfig.php b/framework/core/src/Install/DatabaseConfig.php index b5e4c9ed7..ee81eccfb 100644 --- a/framework/core/src/Install/DatabaseConfig.php +++ b/framework/core/src/Install/DatabaseConfig.php @@ -16,11 +16,11 @@ class DatabaseConfig implements Arrayable { public function __construct( private readonly string $driver, - private readonly string $host, + private readonly ?string $host, private readonly int $port, private string $database, - private readonly string $username, - private readonly string $password, + private readonly ?string $username, + private readonly ?string $password, private readonly string $prefix ) { $this->validate(); @@ -42,15 +42,15 @@ class DatabaseConfig implements Arrayable throw new ValidationFailed('Please specify a database driver.'); } - if (! in_array($this->driver, ['mysql', 'sqlite'])) { + if (! in_array($this->driver, ['mysql', 'sqlite', 'pgsql'])) { throw new ValidationFailed('Currently, only MySQL/MariaDB and SQLite are supported.'); } - if ($this->driver === 'mysql' && empty($this->host)) { + if (in_array($this->driver, ['mysql', 'pgsql']) && empty($this->host)) { throw new ValidationFailed('Please specify the hostname of your database server.'); } - if ($this->driver === 'mysql' && ($this->port < 1 || $this->port > 65535)) { + if (in_array($this->driver, ['mysql', 'pgsql']) && ($this->port < 1 || $this->port > 65535)) { throw new ValidationFailed('Please provide a valid port number between 1 and 65535.'); } @@ -58,7 +58,7 @@ class DatabaseConfig implements Arrayable throw new ValidationFailed('Please specify the database name.'); } - if ($this->driver === 'mysql' && empty($this->username)) { + if (in_array($this->driver, ['mysql', 'pgsql']) && empty($this->username)) { throw new ValidationFailed('Please specify the username for accessing the database.'); } @@ -94,6 +94,15 @@ class DatabaseConfig implements Arrayable 'engine' => 'InnoDB', 'strict' => false, ], + 'pgsql' => [ + 'host' => $this->host, + 'port' => $this->port, + 'username' => $this->username, + 'password' => $this->password, + 'charset' => 'utf8', + 'search_path' => 'public', + 'sslmode' => 'prefer', + ], 'sqlite' => [ 'foreign_key_constraints' => true, ], diff --git a/framework/core/src/Install/Steps/ConnectToDatabase.php b/framework/core/src/Install/Steps/ConnectToDatabase.php index a978c12b1..4b21848f6 100644 --- a/framework/core/src/Install/Steps/ConnectToDatabase.php +++ b/framework/core/src/Install/Steps/ConnectToDatabase.php @@ -13,8 +13,10 @@ use Closure; use Flarum\Install\DatabaseConfig; use Flarum\Install\Step; use Illuminate\Database\Connectors\MySqlConnector; +use Illuminate\Database\Connectors\PostgresConnector; use Illuminate\Database\Connectors\SQLiteConnector; use Illuminate\Database\MySqlConnection; +use Illuminate\Database\PostgresConnection; use Illuminate\Database\SQLiteConnection; use Illuminate\Support\Str; use InvalidArgumentException; @@ -40,6 +42,7 @@ class ConnectToDatabase implements Step match ($config['driver']) { 'mysql' => $this->mysql($config), + 'pgsql' => $this->pgsql($config), 'sqlite' => $this->sqlite($config), default => throw new InvalidArgumentException('Unsupported database driver: '.$config['driver']), }; @@ -53,11 +56,11 @@ class ConnectToDatabase implements Step if (Str::contains($version, 'MariaDB')) { if (version_compare($version, '10.10.0', '<')) { - throw new RangeException('MariaDB version too low. You need at least MariaDB 10.0.5'); + throw new RangeException("MariaDB version ($version) too low. You need at least MariaDB 10.10"); } } else { if (version_compare($version, '5.7.0', '<')) { - throw new RangeException('MySQL version too low. You need at least MySQL 5.7'); + throw new RangeException("MySQL version ($version) too low. You need at least MySQL 5.7"); } } @@ -71,6 +74,27 @@ class ConnectToDatabase implements Step ); } + private function pgsql(array $config): void + { + $pdo = (new PostgresConnector)->connect($config); + + $version = $pdo->query('SHOW server_version')->fetchColumn(); + $version = Str::before($version, ' '); + + if (version_compare($version, '9.5.0', '<')) { + throw new RangeException("PostgreSQL version ($version) too low. You need at least PostgreSQL 9.5"); + } + + ($this->store)( + new PostgresConnection( + $pdo, + $config['database'], + $config['prefix'], + $config + ) + ); + } + private function sqlite(array $config): void { if (! file_exists($config['database'])) { @@ -81,8 +105,8 @@ class ConnectToDatabase implements Step $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'); + if (version_compare($version, '3.35.0', '<')) { + throw new RangeException("SQLite version ($version) too low. You need at least SQLite 3.35.0"); } ($this->store)( diff --git a/framework/core/src/Install/Steps/EnableBundledExtensions.php b/framework/core/src/Install/Steps/EnableBundledExtensions.php index 6ce5ca206..2029347b7 100644 --- a/framework/core/src/Install/Steps/EnableBundledExtensions.php +++ b/framework/core/src/Install/Steps/EnableBundledExtensions.php @@ -24,7 +24,7 @@ use League\Flysystem\Local\LocalFilesystemAdapter; class EnableBundledExtensions implements Step { - public const EXTENSION_WHITELIST = [ + public const DEFAULT_ENABLED_EXTENSIONS = [ 'flarum-approval', 'flarum-bbcode', 'flarum-emoji', @@ -54,7 +54,7 @@ class EnableBundledExtensions implements Step private readonly string $assetPath, ?array $enabledExtensions = null ) { - $this->enabledExtensions = $enabledExtensions ?? self::EXTENSION_WHITELIST; + $this->enabledExtensions = $enabledExtensions ?? self::DEFAULT_ENABLED_EXTENSIONS; } public function getMessage(): string diff --git a/framework/core/src/Notification/Notification.php b/framework/core/src/Notification/Notification.php index 7f9621b16..cdae9397b 100644 --- a/framework/core/src/Notification/Notification.php +++ b/framework/core/src/Notification/Notification.php @@ -159,7 +159,17 @@ class Notification extends AbstractModel */ public function scopeMatchingBlueprint(Builder $query, BlueprintInterface $blueprint): Builder { - return $query->where(static::getBlueprintAttributes($blueprint)); + $attributes = static::getBlueprintAttributes($blueprint); + + $data = $attributes['data']; + unset($attributes['data']); + + return $query->where($attributes) + ->whenPgSql(function ($query) use ($data) { + return $query->whereRaw('data::text = ?', [$data]); + }, function ($query) use ($data) { + return $query->where('data', $data); + }); } /** diff --git a/framework/core/src/Notification/NotificationRepository.php b/framework/core/src/Notification/NotificationRepository.php index 56ecb7845..e4a4a3b1a 100644 --- a/framework/core/src/Notification/NotificationRepository.php +++ b/framework/core/src/Notification/NotificationRepository.php @@ -31,7 +31,7 @@ class NotificationRepository { $primaries = Notification::query() ->selectRaw('MAX(id) AS id') - ->selectRaw('SUM(read_at IS NULL) AS unread_count') + ->selectRaw('COUNT(read_at IS NULL) AS unread_count') ->where('user_id', $user->id) ->whereIn('type', $user->getAlertableNotificationTypes()) ->where('is_deleted', false) diff --git a/framework/core/src/Search/Database/AbstractSearcher.php b/framework/core/src/Search/Database/AbstractSearcher.php index 59de0166e..dec58b3b6 100644 --- a/framework/core/src/Search/Database/AbstractSearcher.php +++ b/framework/core/src/Search/Database/AbstractSearcher.php @@ -33,7 +33,7 @@ abstract class AbstractSearcher implements SearcherInterface $query = $this->getQuery($actor); $search = new DatabaseSearchState($actor, $criteria->isFulltext()); - $search->setQuery($query->getQuery()); + $search->setQuery($query); $this->filters->apply($search, $criteria->filters); @@ -45,6 +45,8 @@ abstract class AbstractSearcher implements SearcherInterface $mutator($search, $criteria); } + $query = $search->getQuery(); + // Execute the search query and retrieve the results. We get one more // results than the user asked for, so that we can say if there are more // results. If there are, we will get rid of that extra result. diff --git a/framework/core/src/Search/Database/DatabaseSearchState.php b/framework/core/src/Search/Database/DatabaseSearchState.php index 1fd0c42f5..8b3ff4718 100644 --- a/framework/core/src/Search/Database/DatabaseSearchState.php +++ b/framework/core/src/Search/Database/DatabaseSearchState.php @@ -10,7 +10,7 @@ namespace Flarum\Search\Database; use Flarum\Search\SearchState; -use Illuminate\Database\Query\Builder; +use Illuminate\Database\Eloquent\Builder; class DatabaseSearchState extends SearchState { diff --git a/framework/core/src/Settings/SettingsServiceProvider.php b/framework/core/src/Settings/SettingsServiceProvider.php index ec7e25672..3f3d68e7b 100644 --- a/framework/core/src/Settings/SettingsServiceProvider.php +++ b/framework/core/src/Settings/SettingsServiceProvider.php @@ -30,6 +30,7 @@ class SettingsServiceProvider extends AbstractServiceProvider 'search_driver_Flarum\Group\Group' => 'default', 'search_driver_Flarum\Post\Post' => 'default', 'search_driver_Flarum\Http\AccessToken' => 'default', + 'pgsql_search_configuration' => 'english', ]); }); diff --git a/framework/core/src/User/Search/Filter/EmailFilter.php b/framework/core/src/User/Search/Filter/EmailFilter.php index 90e446c2f..39d202c39 100644 --- a/framework/core/src/User/Search/Filter/EmailFilter.php +++ b/framework/core/src/User/Search/Filter/EmailFilter.php @@ -13,7 +13,7 @@ use Flarum\Search\Database\DatabaseSearchState; use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; -use Illuminate\Database\Query\Builder; +use Illuminate\Database\Eloquent\Builder; /** * @implements FilterInterface diff --git a/framework/core/src/User/Search/Filter/GroupFilter.php b/framework/core/src/User/Search/Filter/GroupFilter.php index aee788411..e45a1f0eb 100644 --- a/framework/core/src/User/Search/Filter/GroupFilter.php +++ b/framework/core/src/User/Search/Filter/GroupFilter.php @@ -15,7 +15,7 @@ use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; use Flarum\User\User; -use Illuminate\Database\Query\Builder; +use Illuminate\Database\Eloquent\Builder; /** * @implements FilterInterface @@ -50,7 +50,7 @@ class GroupFilter implements FilterInterface $groupQuery = Group::whereVisibleTo($actor) ->join('group_user', 'groups.id', 'group_user.group_id') - ->where(function (\Illuminate\Database\Eloquent\Builder $query) use ($ids, $names) { + ->where(function (Builder $query) use ($ids, $names) { $query->whereIn('groups.id', $ids) ->orWhereIn($query->raw('lower(name_singular)'), $names) ->orWhereIn($query->raw('lower(name_plural)'), $names); diff --git a/framework/core/src/User/User.php b/framework/core/src/User/User.php index 07c6f391d..1038c056c 100644 --- a/framework/core/src/User/User.php +++ b/framework/core/src/User/User.php @@ -367,7 +367,9 @@ class User extends AbstractModel public function getNewNotificationCount(): int { return $this->unreadNotifications() - ->where('created_at', '>', $this->read_notifications_at ?? 0) + ->when($this->read_notifications_at, function (Builder|HasMany $query) { + $query->where('created_at', '>', $this->read_notifications_at); + }) ->count(); } diff --git a/framework/core/tests/integration/api/discussions/ListTest.php b/framework/core/tests/integration/api/discussions/ListTest.php index ac8d69963..6a8aaf54e 100644 --- a/framework/core/tests/integration/api/discussions/ListTest.php +++ b/framework/core/tests/integration/api/discussions/ListTest.php @@ -85,7 +85,11 @@ class ListTest extends TestCase ]) ); - $data = json_decode($response->getBody()->getContents(), true)['data']; + $body = $response->getBody()->getContents(); + + $this->assertEquals(200, $response->getStatusCode(), $body); + + $data = json_decode($body, true)['data']; // Order-independent comparison $this->assertEqualsCanonicalizing(['2', '3'], Arr::pluck($data, 'id'), 'IDs do not match'); @@ -123,7 +127,9 @@ class ListTest extends TestCase ]) ); - $data = json_decode($response->getBody()->getContents(), true)['data']; + $data = json_decode($body = $response->getBody()->getContents(), true)['data'] ?? null; + + $this->assertEquals(200, $response->getStatusCode(), $body); // Order-independent comparison $this->assertEquals(['3'], Arr::pluck($data, 'id'), 'IDs do not match'); diff --git a/framework/core/tests/integration/api/discussions/ListWithFulltextSearchTest.php b/framework/core/tests/integration/api/discussions/ListWithFulltextSearchTest.php index 395bf55ec..40bd49463 100644 --- a/framework/core/tests/integration/api/discussions/ListWithFulltextSearchTest.php +++ b/framework/core/tests/integration/api/discussions/ListWithFulltextSearchTest.php @@ -85,7 +85,10 @@ class ListWithFulltextSearchTest extends TestCase ]) ); - $data = json_decode($response->getBody()->getContents(), true); + $data = json_decode($body = $response->getBody()->getContents(), true); + + $this->assertEquals(200, $response->getStatusCode(), $body); + $ids = array_map(function ($row) { return $row['id']; }, $data['data']); diff --git a/framework/core/tests/integration/api/notifications/UpdateTest.php b/framework/core/tests/integration/api/notifications/UpdateTest.php index 4a34b4739..331c7d84d 100644 --- a/framework/core/tests/integration/api/notifications/UpdateTest.php +++ b/framework/core/tests/integration/api/notifications/UpdateTest.php @@ -9,6 +9,7 @@ namespace Flarum\Tests\integration\api\notifications; +use Carbon\Carbon; use Flarum\Discussion\Discussion; use Flarum\Notification\Notification; use Flarum\Post\Post; @@ -38,7 +39,7 @@ class UpdateTest extends TestCase ['id' => 1, 'discussion_id' => 1, 'user_id' => 2, 'type' => 'comment', 'content' => 'Foo'], ], Notification::class => [ - ['id' => 1, 'user_id' => 2, 'from_user_id' => 1, 'type' => 'discussionRenamed', 'subject_id' => 1, 'read_at' => null], + ['id' => 1, 'user_id' => 2, 'from_user_id' => 1, 'type' => 'discussionRenamed', 'subject_id' => 1, 'read_at' => null, 'created_at' => Carbon::now()], ] ]); } diff --git a/framework/core/tests/integration/api/posts/DeleteTest.php b/framework/core/tests/integration/api/posts/DeleteTest.php index cf87ecd79..1bcb021bf 100644 --- a/framework/core/tests/integration/api/posts/DeleteTest.php +++ b/framework/core/tests/integration/api/posts/DeleteTest.php @@ -30,19 +30,20 @@ class DeleteTest extends TestCase $this->prepareDatabase([ User::class => [ + $this->normalUser(), ['id' => 3, 'username' => 'acme', 'email' => 'acme@machine.local', 'is_email_confirmed' => 1], ['id' => 4, 'username' => 'acme2', 'email' => 'acme2@machine.local', 'is_email_confirmed' => 1], ], Discussion::class => [ - ['id' => 3, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 2, 'first_post_id' => 1, 'comment_count' => 5, 'last_post_number' => 5, 'last_post_id' => 10], + ['id' => 3, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 2, 'first_post_id' => 5, 'comment_count' => 5, 'last_post_number' => 5, 'last_post_id' => 10], ], Post::class => [ - ['id' => 5, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 1], - ['id' => 6, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 2], - ['id' => 7, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 3], - ['id' => 8, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 4], - ['id' => 9, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 5], - ['id' => 10, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 6], + ['id' => 5, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(2)->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 1], + ['id' => 6, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(3)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 2], + ['id' => 7, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(4)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 3], + ['id' => 8, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(5)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 4], + ['id' => 9, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(6)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 5], + ['id' => 10, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(7)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 6], ], 'discussion_user' => [ ['discussion_id' => 3, 'user_id' => 2, 'last_read_post_number' => 6], diff --git a/framework/core/tests/integration/api/users/GroupSearchTest.php b/framework/core/tests/integration/api/users/GroupSearchTest.php index f07e17c4a..63f1a84eb 100644 --- a/framework/core/tests/integration/api/users/GroupSearchTest.php +++ b/framework/core/tests/integration/api/users/GroupSearchTest.php @@ -46,7 +46,7 @@ class GroupSearchTest extends TestCase { $response = $this->createRequest(['admin'], 1); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents()); } /** diff --git a/framework/core/tests/integration/api/users/ListTest.php b/framework/core/tests/integration/api/users/ListTest.php index a16b73b7a..ef21abc4c 100644 --- a/framework/core/tests/integration/api/users/ListTest.php +++ b/framework/core/tests/integration/api/users/ListTest.php @@ -89,7 +89,7 @@ class ListTest extends TestCase $this->assertEquals(200, $response->getStatusCode()); $data = json_decode($response->getBody()->getContents(), true)['data']; - $this->assertEquals(['1', '2'], Arr::pluck($data, 'id')); + $this->assertEqualsCanonicalizing(['1', '2'], Arr::pluck($data, 'id')); } /** diff --git a/framework/core/tests/integration/extenders/ModelPrivateTest.php b/framework/core/tests/integration/extenders/ModelPrivateTest.php index 97e116296..1642a9bda 100644 --- a/framework/core/tests/integration/extenders/ModelPrivateTest.php +++ b/framework/core/tests/integration/extenders/ModelPrivateTest.php @@ -9,6 +9,7 @@ namespace Flarum\Tests\integration\extenders; +use Carbon\Carbon; use Flarum\Discussion\Discussion; use Flarum\Extend; use Flarum\Testing\integration\RetrievesAuthorizedUsers; @@ -38,6 +39,7 @@ class ModelPrivateTest extends TestCase $discussion = Discussion::create([ 'title' => 'Some Discussion', 'user_id' => $user->id, + 'created_at' => Carbon::now(), ]); $this->assertNull($discussion->is_private); @@ -62,10 +64,12 @@ class ModelPrivateTest extends TestCase $privateDiscussion = Discussion::create([ 'title' => 'Private Discussion', 'user_id' => $user->id, + 'created_at' => Carbon::now(), ]); $publicDiscussion = Discussion::create([ 'title' => 'Public Discussion', 'user_id' => $user->id, + 'created_at' => Carbon::now(), ]); $this->assertTrue($privateDiscussion->is_private); @@ -89,10 +93,12 @@ class ModelPrivateTest extends TestCase $privateDiscussion = Discussion::create([ 'title' => 'Private Discussion', 'user_id' => $user->id, + 'created_at' => Carbon::now(), ]); $publicDiscussion = Discussion::create([ 'title' => 'Public Discussion', 'user_id' => $user->id, + 'created_at' => Carbon::now(), ]); $this->assertTrue($privateDiscussion->is_private); @@ -122,10 +128,12 @@ class ModelPrivateTest extends TestCase $privateDiscussion = Discussion::create([ 'title' => 'Private Discussion', 'user_id' => $user->id, + 'created_at' => Carbon::now(), ]); $publicDiscussion = Discussion::create([ 'title' => 'Public Discussion', 'user_id' => $user->id, + 'created_at' => Carbon::now(), ]); $this->assertTrue($privateDiscussion->is_private); diff --git a/framework/core/views/install/install.php b/framework/core/views/install/install.php index 37bb260f2..d94ef7cf9 100644 --- a/framework/core/views/install/install.php +++ b/framework/core/views/install/install.php @@ -13,9 +13,9 @@
-
+
- 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. + Warning: Please keep in mind that while Flarum supports SQLite and PostgreSQL, 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.
@@ -25,6 +25,7 @@
@@ -34,20 +35,20 @@ -
+
- - + +
- - + +
- - + +
@@ -93,7 +94,7 @@ group.style.display = 'none'; }); - const groups = document.querySelectorAll('[data-group="' + this.value + '"]'); + const groups = document.querySelectorAll('[data-group*="' + this.value + '"]'); groups.forEach(function(group) { group.style.display = 'block'; diff --git a/php-packages/testing/src/integration/Setup/SetupScript.php b/php-packages/testing/src/integration/Setup/SetupScript.php index 5b190cf94..0d0f2471d 100644 --- a/php-packages/testing/src/integration/Setup/SetupScript.php +++ b/php-packages/testing/src/integration/Setup/SetupScript.php @@ -40,7 +40,11 @@ class SetupScript { $this->driver = getenv('DB_DRIVER') ?: 'mysql'; $this->host = getenv('DB_HOST') ?: 'localhost'; - $this->port = intval(getenv('DB_PORT') ?: 3306); + $this->port = intval(getenv('DB_PORT') ?: match ($this->driver) { + 'mysql' => 3306, + 'pgsql' => 5432, + default => 0, + }); $this->name = getenv('DB_DATABASE') ?: 'flarum_test'; $this->user = getenv('DB_USERNAME') ?: 'root'; $this->pass = getenv('DB_PASSWORD') ?? 'root'; diff --git a/php-packages/testing/src/integration/TestCase.php b/php-packages/testing/src/integration/TestCase.php index 5de440004..0a6362e0e 100644 --- a/php-packages/testing/src/integration/TestCase.php +++ b/php-packages/testing/src/integration/TestCase.php @@ -201,6 +201,10 @@ abstract class TestCase extends \PHPUnit\Framework\TestCase */ $this->database()->getSchemaBuilder()->disableForeignKeyConstraints(); + if ($this->database()->getDriverName() === 'pgsql') { + $this->database()->statement("SET session_replication_role = 'replica'"); + } + $databaseContent = []; foreach ($this->databaseContent as $tableOrModelClass => $_rows) { @@ -224,6 +228,8 @@ abstract class TestCase extends \PHPUnit\Framework\TestCase } } + $tables = []; + // Then, insert all rows required for this test case. foreach ($databaseContent as $table => $data) { foreach ($data['rows'] as $row) { @@ -238,9 +244,24 @@ abstract class TestCase extends \PHPUnit\Framework\TestCase } $this->database()->table($table)->updateOrInsert($unique, $row); + + if (isset($row['id'])) { + $tables[$table] = 'id'; + } } } + if ($this->database()->getDriverName() === 'pgsql') { + // PgSQL doesn't auto-increment the sequence when inserting the IDs manually. + foreach ($tables as $table => $id) { + $wrappedTable = $this->database()->getSchemaGrammar()->wrapTable($table); + $seq = $this->database()->getSchemaGrammar()->wrapTable($table.'_'.$id.'_seq'); + $this->database()->statement("SELECT setval('$seq', (SELECT MAX($id) FROM $wrappedTable))"); + } + + $this->database()->statement("SET session_replication_role = 'origin'"); + } + // And finally, turn on foreign key checks again. $this->database()->getSchemaBuilder()->enableForeignKeyConstraints(); }