diff --git a/.github/workflows/REUSABLE_backend.yml b/.github/workflows/REUSABLE_backend.yml index 70adb15b8e..46e823c8e4 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 6a9b000882..5d8a322d84 100644 --- a/extensions/approval/tests/integration/api/ApprovePostsTest.php +++ b/extensions/approval/tests/integration/api/ApprovePostsTest.php @@ -11,8 +11,12 @@ 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 @@ protected function setUp(): void $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 82b8857f93..7099f3fa96 100644 --- a/extensions/approval/tests/integration/api/CreatePostsTest.php +++ b/extensions/approval/tests/integration/api/CreatePostsTest.php @@ -11,9 +11,12 @@ 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 @@ protected function setUp(): void $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 @@ protected function setUp(): void ['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 @@ protected function setUp(): void '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 a124abd521..aec1aefd07 100644 --- a/extensions/flags/src/Api/Resource/FlagResource.php +++ b/extensions/flags/src/Api/Resource/FlagResource.php @@ -53,7 +53,10 @@ public function model(): string 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 6f137c2658..57f21344df 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\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 @@ protected function setUp(): void ['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 @@ public function admin_can_see_one_flag_per_visible_post() ]) ); - $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 @@ public function mod_can_see_one_flag_per_visible_post() $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 dda8f27ede..e1a6526cb3 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\TestCase; use Flarum\User\User; use Illuminate\Support\Arr; +use Illuminate\Support\Carbon; class ListWithTagsTest extends TestCase { @@ -86,16 +87,16 @@ protected function setUp(): void ], 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 @@ public function admin_can_see_one_flag_per_post() ]) ); - $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 @@ public function mod_can_see_one_flag_per_post() $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 7ae42b5139..02100a7d84 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 @@ protected function setup(): void $this->extension('flarum-tags', 'flarum-flags'); $this->prepareDatabase([ - 'users' => [ + User::class => [ $this->normalUser(), [ 'id' => 3, @@ -56,7 +61,7 @@ protected function setup(): void ['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 @@ protected function setup(): void ['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 @@ protected function setup(): void ['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 @@ protected function setup(): void ['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 d40e9f628b..9f16b286f8 100644 --- a/extensions/likes/src/Api/PostResourceFields.php +++ b/extensions/likes/src/Api/PostResourceFields.php @@ -58,6 +58,7 @@ public function __invoke(): array // 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 5c8df77b92..37c5a84154 100644 --- a/extensions/likes/tests/integration/api/ListPostsTest.php +++ b/extensions/likes/tests/integration/api/ListPostsTest.php @@ -54,17 +54,17 @@ protected function setUp(): void ['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 6fb2349f59..6d5cb2c2d8 100644 --- a/extensions/lock/src/Filter/LockedFilter.php +++ b/extensions/lock/src/Filter/LockedFilter.php @@ -12,7 +12,7 @@ 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 0256e947d6..6be5870a18 100644 --- a/extensions/mentions/tests/integration/api/GroupMentionsTest.php +++ b/extensions/mentions/tests/integration/api/GroupMentionsTest.php @@ -249,9 +249,11 @@ public function mentioning_a_virtual_group_as_an_admin_user_does_not_work() ]) ); - $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 f33abc2fc0..d5214aebb1 100644 --- a/extensions/mentions/tests/integration/api/ListPostsTest.php +++ b/extensions/mentions/tests/integration/api/ListPostsTest.php @@ -116,35 +116,35 @@ protected function prepareMentionedByData(): void { $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 @@ public function mentioned_by_relation_returns_limited_results_and_shows_only_vis ])->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 822eaf1add..a408b4a3b0 100644 --- a/extensions/mentions/tests/integration/api/PostMentionsTest.php +++ b/extensions/mentions/tests/integration/api/PostMentionsTest.php @@ -51,7 +51,7 @@ protected function setUp(): void ['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 @@ public function mentioning_a_valid_post_with_old_format_doesnt_work() ]) ); - $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 @@ public function mentioning_a_valid_post_with_new_format_with_smart_quotes_works_ ]) ); - $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 @@ public function editing_a_post_with_deleted_author_that_has_a_mention_works() ]) ); - $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 050e8275b6..726b0c21f3 100644 --- a/extensions/mentions/tests/integration/api/UserMentionsTest.php +++ b/extensions/mentions/tests/integration/api/UserMentionsTest.php @@ -38,6 +38,7 @@ protected function setUp(): void ['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 @@ public function editing_a_post_with_deleted_author_that_has_a_mention_works() ]) ); - $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 8938b03827..e90d6c0abe 100644 --- a/extensions/statistics/src/Api/Controller/ShowStatisticsData.php +++ b/extensions/statistics/src/Api/Controller/ShowStatisticsData.php @@ -11,6 +11,7 @@ use Carbon\Carbon; use DateTime; +use Exception; use Flarum\Discussion\Discussion; use Flarum\Http\Exception\InvalidParameterException; use Flarum\Http\RequestUtil; @@ -130,11 +131,19 @@ private function getTimedCounts(Builder $query, string $column, ?DateTime $start $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 a8b57eebc0..dcb53e3550 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 @@ public function __invoke(DatabaseSearchState $state, SearchCriteria $criteria): $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 @@ public function __invoke(DatabaseSearchState $state, SearchCriteria $criteria): // 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 74ab036ebf..af008f649c 100644 --- a/extensions/sticky/src/Query/StickyFilter.php +++ b/extensions/sticky/src/Query/StickyFilter.php @@ -12,7 +12,7 @@ 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 85ab86c27c..de1b15cc2b 100644 --- a/extensions/sticky/tests/integration/api/ListDiscussionsTest.php +++ b/extensions/sticky/tests/integration/api/ListDiscussionsTest.php @@ -62,11 +62,13 @@ public function list_discussions_shows_sticky_first_as_guest() $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 @@ public function list_discussions_shows_stick_first_on_a_tag() ]) ); - $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 1554ec37a6..ec835c624e 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 @@ protected function setUp(): void $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 6e6f6d2f44..993949ebd7 100644 --- a/extensions/subscriptions/src/Filter/SubscriptionFilter.php +++ b/extensions/subscriptions/src/Filter/SubscriptionFilter.php @@ -14,7 +14,7 @@ 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 55e7476a5d..81423546a5 100644 --- a/extensions/subscriptions/tests/integration/api/discussions/ReplyNotificationTest.php +++ b/extensions/subscriptions/tests/integration/api/discussions/ReplyNotificationTest.php @@ -41,15 +41,15 @@ protected function setUp(): void ['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' => 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' => 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)->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 21a243867a..b592ed7e9f 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 @@ protected function setUp(): void $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 ed9080066e..e4bfbf3c47 100644 --- a/extensions/suspend/src/Query/SuspendedFilter.php +++ b/extensions/suspend/src/Query/SuspendedFilter.php @@ -15,7 +15,7 @@ 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 5c82c6bfde..552072aaa3 100644 --- a/extensions/tags/src/Search/Filter/TagFilter.php +++ b/extensions/tags/src/Search/Filter/TagFilter.php @@ -16,8 +16,9 @@ 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 @@ protected function constrain(Builder $query, string|array $rawSlugs, bool $negat $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 @@ protected function constrain(Builder $query, string|array $rawSlugs, bool $negat $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 c17c0f6b58..7ad4838107 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 474013041d..77c0f10e26 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 feca76e015..47d3eeed08 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 9626b8cb20..abf6166581 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 2e362e9699..1389583b4c 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 7f540a16f0..48c35db0c9 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 412fe8d983..f822064310 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 @@ $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 303f2027fe..09b392e0c9 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 @@ $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 e49130d5b6..714a8482a3 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 1ba4e83e14..5574153faa 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 @@ $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 0000000000..1129e4f03c --- /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 0000000000..c433d0c087 --- /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 0000000000..760c0b82dd --- /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 dea5431a16..134340a552 100644 --- a/framework/core/src/Admin/Content/AdminPayload.php +++ b/framework/core/src/Admin/Content/AdminPayload.php @@ -62,6 +62,7 @@ public function __invoke(Document $document, Request $request): void $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 71746fdd21..8a5be23013 100644 --- a/framework/core/src/Api/Resource/UserResource.php +++ b/framework/core/src/Api/Resource/UserResource.php @@ -217,7 +217,11 @@ public function fields(): array || $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 cb05162160..ce43aa623d 100644 --- a/framework/core/src/Database/DatabaseServiceProvider.php +++ b/framework/core/src/Database/DatabaseServiceProvider.php @@ -18,7 +18,9 @@ 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 @@ public function register(): void }); } + 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 37be1e5258..2be94ab292 100644 --- a/framework/core/src/Database/Migrator.php +++ b/framework/core/src/Database/Migrator.php @@ -69,8 +69,38 @@ public function runMigrationList(string $path, array $migrations, ?Extension $ex // 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 @@ public function reset(string $path, ?Extension $extension = null): int 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 @@ public function installFromSchema(string $path, string $driver): bool $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 @@ public function installFromSchema(string $path, string $driver): bool $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 619c3dabbb..a74fdd8c89 100644 --- a/framework/core/src/Discussion/Discussion.php +++ b/framework/core/src/Discussion/Discussion.php @@ -183,7 +183,7 @@ public function setLastPost(Post $post): static 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 ef5c44dc79..138cbc975f 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\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 c66bac114a..bd93a2940a 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\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 @@ public function filter(SearchState $state, string|array $value, bool $negate): v $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 e9e52cae86..37fca15605 100644 --- a/framework/core/src/Discussion/Search/Filter/HiddenFilter.php +++ b/framework/core/src/Discussion/Search/Filter/HiddenFilter.php @@ -12,7 +12,7 @@ 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 6c12ac6359..945c65d419 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\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 @@ protected function constrain(Builder $query, User $actor, bool $negate): void 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 ada06a184b..44d2489c51 100644 --- a/framework/core/src/Discussion/Search/FulltextFilter.php +++ b/framework/core/src/Discussion/Search/FulltextFilter.php @@ -14,37 +14,57 @@ 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() - ); - }); - }); - - return; - } + $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() + ); + }); + }); + } + + 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 @@ public function search(SearchState $state, string $value): void $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 @@ public function search(SearchState $state, string $value): void // 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 @@ public function search(SearchState $state, string $value): void ->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 f777fed1eb..7aca423ac1 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 0f5aa22112..075bb3ff95 100644 --- a/framework/core/src/Foundation/ApplicationInfoProvider.php +++ b/framework/core/src/Foundation/ApplicationInfoProvider.php @@ -71,7 +71,7 @@ public function identifyQueueDriver(): string 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 @@ public function identifyDatabaseDriver(): string { 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 0000000000..340711044d --- /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 2c55cfb708..5b7fa73492 100644 --- a/framework/core/src/Foundation/ErrorHandling/HandledError.php +++ b/framework/core/src/Foundation/ErrorHandling/HandledError.php @@ -30,7 +30,8 @@ public static function unknown(Throwable $error): static 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 @@ public function getStatusCode(): int 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 93e25378ae..8c368321f0 100644 --- a/framework/core/src/Foundation/ErrorServiceProvider.php +++ b/framework/core/src/Foundation/ErrorServiceProvider.php @@ -13,6 +13,7 @@ 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 @@ public function register(): void 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 e6413c6509..60be408ff5 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 5ef3de9901..7e58774d9f 100644 --- a/framework/core/src/Install/Console/UserDataProvider.php +++ b/framework/core/src/Install/Console/UserDataProvider.php @@ -42,20 +42,31 @@ public function configure(Installation $installation): Installation 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 (in_array($driver, ['mysql', 'pgsql'])) { + $host = $this->ask('Database host (required):'); + + if (Str::contains($host, ':')) { + list($host, $port) = explode(':', $host, 2); + } - 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 f066c794c1..fee31cd69c 100644 --- a/framework/core/src/Install/Controller/InstallController.php +++ b/framework/core/src/Install/Controller/InstallController.php @@ -76,20 +76,25 @@ public function handle(Request $request): ResponseInterface 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 b5e4c9ed78..ee81eccfbd 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 @@ private function validate(): void 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 @@ private function validate(): void 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 @@ private function driverOptions(): array '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 a978c12b11..4b21848f68 100644 --- a/framework/core/src/Install/Steps/ConnectToDatabase.php +++ b/framework/core/src/Install/Steps/ConnectToDatabase.php @@ -13,8 +13,10 @@ 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 @@ public function run(): void 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 @@ private function mysql(array $config): void 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 @@ private function mysql(array $config): void ); } + 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 @@ private function sqlite(array $config): void $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 6ce5ca2064..2029347b79 100644 --- a/framework/core/src/Install/Steps/EnableBundledExtensions.php +++ b/framework/core/src/Install/Steps/EnableBundledExtensions.php @@ -24,7 +24,7 @@ class EnableBundledExtensions implements Step { - public const EXTENSION_WHITELIST = [ + public const DEFAULT_ENABLED_EXTENSIONS = [ 'flarum-approval', 'flarum-bbcode', 'flarum-emoji', @@ -54,7 +54,7 @@ public function __construct( 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 7f9621b167..cdae9397ba 100644 --- a/framework/core/src/Notification/Notification.php +++ b/framework/core/src/Notification/Notification.php @@ -159,7 +159,17 @@ public function scopeWhereSubjectModel(Builder $query, string $class): Builder */ 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 56ecb78455..e4a4a3b1a0 100644 --- a/framework/core/src/Notification/NotificationRepository.php +++ b/framework/core/src/Notification/NotificationRepository.php @@ -31,7 +31,7 @@ public function query(User $user, ?int $limit = null, int $offset = 0): Builder { $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 59de0166e0..dec58b3b6a 100644 --- a/framework/core/src/Search/Database/AbstractSearcher.php +++ b/framework/core/src/Search/Database/AbstractSearcher.php @@ -33,7 +33,7 @@ public function search(SearchCriteria $criteria): SearchResults $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 @@ public function search(SearchCriteria $criteria): SearchResults $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 1fd0c42f5b..8b3ff4718d 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 ec7e25672f..3f3d68e7b9 100644 --- a/framework/core/src/Settings/SettingsServiceProvider.php +++ b/framework/core/src/Settings/SettingsServiceProvider.php @@ -30,6 +30,7 @@ public function register(): void '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 90e446c2f4..39d202c39a 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\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 aee788411e..e45a1f0eb4 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\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 @@ protected function constrain(Builder $query, User $actor, string|array $rawQuery $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 07c6f391d5..1038c056c7 100644 --- a/framework/core/src/User/User.php +++ b/framework/core/src/User/User.php @@ -367,7 +367,9 @@ protected function getUnreadNotifications(): Collection 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 ac8d699639..6a8aaf54e6 100644 --- a/framework/core/tests/integration/api/discussions/ListTest.php +++ b/framework/core/tests/integration/api/discussions/ListTest.php @@ -85,7 +85,11 @@ public function author_filter_works() ]) ); - $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 @@ public function created_filter_works_with_date() ]) ); - $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 395bf55ec5..40bd494638 100644 --- a/framework/core/tests/integration/api/discussions/ListWithFulltextSearchTest.php +++ b/framework/core/tests/integration/api/discussions/ListWithFulltextSearchTest.php @@ -85,7 +85,10 @@ public function can_search_for_word_or_title_in_post() ]) ); - $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 4a34b47390..331c7d84d8 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 @@ protected function setUp(): void ['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 cf87ecd79d..1bcb021bfb 100644 --- a/framework/core/tests/integration/api/posts/DeleteTest.php +++ b/framework/core/tests/integration/api/posts/DeleteTest.php @@ -30,19 +30,20 @@ protected function setUp(): void $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 f07e17c4a1..63f1a84eb1 100644 --- a/framework/core/tests/integration/api/users/GroupSearchTest.php +++ b/framework/core/tests/integration/api/users/GroupSearchTest.php @@ -46,7 +46,7 @@ public function allows_group_filter_for_admin() { $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 a16b73b7a0..ef21abc4c0 100644 --- a/framework/core/tests/integration/api/users/ListTest.php +++ b/framework/core/tests/integration/api/users/ListTest.php @@ -89,7 +89,7 @@ public function shows_full_results_without_search_or_filter() $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 97e116296e..1642a9bda3 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 @@ public function discussion_isnt_saved_as_private_by_default() $discussion = Discussion::create([ 'title' => 'Some Discussion', 'user_id' => $user->id, + 'created_at' => Carbon::now(), ]); $this->assertNull($discussion->is_private); @@ -62,10 +64,12 @@ public function discussion_is_saved_as_private_if_privacy_checker_added() $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 @@ public function discussion_is_saved_as_private_if_privacy_checker_added_via_invo $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 @@ public function private_checkers_that_return_false_dont_matter() $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 37bb260f21..d94ef7cf95 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 5b190cf944..0d0f2471db 100644 --- a/php-packages/testing/src/integration/Setup/SetupScript.php +++ b/php-packages/testing/src/integration/Setup/SetupScript.php @@ -40,7 +40,11 @@ public function __construct() { $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 5de440004a..0a6362e0e5 100644 --- a/php-packages/testing/src/integration/TestCase.php +++ b/php-packages/testing/src/integration/TestCase.php @@ -201,6 +201,10 @@ protected function populateDatabase(): void */ $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 @@ protected function populateDatabase(): void } } + $tables = []; + // Then, insert all rows required for this test case. foreach ($databaseContent as $table => $data) { foreach ($data['rows'] as $row) { @@ -238,9 +244,24 @@ protected function populateDatabase(): void } $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(); }