feat: add support for PgSQL (#3985)

* feat: add support for `PgSQL`
* chore: generate dump
* feat: query exception errors db driver hint
* feat: allow defining supported databases
* chore: review comments
* feat: setting for pgsql preferred search config
This commit is contained in:
Sami Mazouz 2024-06-22 08:03:56 +01:00 committed by GitHub
parent d04cda6ca3
commit 379298acb0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
76 changed files with 2097 additions and 261 deletions

View File

@ -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

View File

@ -11,8 +11,12 @@ namespace Flarum\Approval\Tests\integration\api;
use Carbon\Carbon;
use Flarum\Approval\Tests\integration\InteractsWithUnapprovedContent;
use Flarum\Discussion\Discussion;
use Flarum\Group\Group;
use Flarum\Post\Post;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Flarum\User\User;
class ApprovePostsTest extends TestCase
{
@ -26,23 +30,23 @@ class ApprovePostsTest extends TestCase
$this->extension('flarum-approval');
$this->prepareDatabase([
'users' => [
User::class => [
['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1],
$this->normalUser(),
['id' => 3, 'username' => 'acme', 'email' => 'acme@machine.local', 'is_email_confirmed' => 1],
['id' => 4, 'username' => 'luceos', 'email' => 'luceos@machine.local', 'is_email_confirmed' => 1],
],
'discussions' => [
Discussion::class => [
['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 1, 'comment_count' => 1, 'is_approved' => 1],
],
'posts' => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'hidden_at' => 0, 'is_approved' => 1, 'number' => 1],
['id' => 2, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'hidden_at' => 0, 'is_approved' => 1, 'number' => 2],
['id' => 3, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'hidden_at' => 0, 'is_approved' => 0, 'number' => 3],
Post::class => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'hidden_at' => null, 'is_approved' => 1, 'number' => 1],
['id' => 2, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'hidden_at' => null, 'is_approved' => 1, 'number' => 2],
['id' => 3, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'hidden_at' => null, 'is_approved' => 0, 'number' => 3],
['id' => 4, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'hidden_at' => Carbon::now(), 'is_approved' => 1, 'number' => 4],
['id' => 5, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'hidden_at' => 0, 'is_approved' => 0, 'number' => 5],
['id' => 5, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', '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],
],

View File

@ -11,9 +11,12 @@ namespace Flarum\Approval\Tests\integration\api;
use Carbon\Carbon;
use Flarum\Approval\Tests\integration\InteractsWithUnapprovedContent;
use Flarum\Discussion\Discussion;
use Flarum\Group\Group;
use Flarum\Post\Post;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Flarum\User\User;
class CreatePostsTest extends TestCase
{
@ -27,18 +30,18 @@ class CreatePostsTest extends TestCase
$this->extension('flarum-flags', 'flarum-approval');
$this->prepareDatabase([
'users' => [
User::class => [
['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1],
$this->normalUser(),
['id' => 3, 'username' => 'acme', 'email' => 'acme@machine.local', 'is_email_confirmed' => 1],
['id' => 4, 'username' => 'luceos', 'email' => 'luceos@machine.local', 'is_email_confirmed' => 1],
],
'discussions' => [
Discussion::class => [
['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 1, 'comment_count' => 1, 'is_approved' => 1],
['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 2, 'comment_count' => 1, 'is_approved' => 0],
['id' => 3, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 3, 'comment_count' => 1, 'is_approved' => 0],
],
'posts' => [
Post::class => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
['id' => 2, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 2],
['id' => 3, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 3],
@ -49,7 +52,7 @@ class CreatePostsTest extends TestCase
['id' => 8, 'discussion_id' => 3, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 2],
['id' => 9, 'discussion_id' => 3, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 0, 'number' => 3],
],
'groups' => [
Group::class => [
['id' => 4, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0],
['id' => 5, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0],
],
@ -60,6 +63,7 @@ class CreatePostsTest extends TestCase
'group_permission' => [
['group_id' => 4, 'permission' => 'discussion.startWithoutApproval'],
['group_id' => 5, 'permission' => 'discussion.replyWithoutApproval'],
['group_id' => Group::MEMBER_ID, 'permission' => 'postWithoutThrottle'],
]
]);
}

View File

@ -53,7 +53,10 @@ class FlagResource extends AbstractDatabaseResource
public function query(Context $context): object
{
if ($context->listing(self::class)) {
$query = Flag::query()->groupBy('post_id');
$query = Flag::query()->whenPgSql(
fn (Builder $query) => $query->distinct('post_id')->orderBy('post_id'),
else: fn (Builder $query) => $query->groupBy('post_id')
);
$this->scope($query, $context);

View File

@ -9,6 +9,7 @@
namespace Flarum\Flags\Tests\integration\api\flags;
use Carbon\Carbon;
use Flarum\Discussion\Discussion;
use Flarum\Flags\Flag;
use Flarum\Group\Group;
@ -16,6 +17,7 @@ use Flarum\Post\Post;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Flarum\User\User;
use Illuminate\Database\PostgresConnection;
use Illuminate\Support\Arr;
class ListTest extends TestCase
@ -58,12 +60,12 @@ class ListTest extends TestCase
['id' => 4, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>', 'is_private' => true],
],
Flag::class => [
['id' => 1, 'post_id' => 1, 'user_id' => 1],
['id' => 2, 'post_id' => 1, 'user_id' => 2],
['id' => 3, 'post_id' => 1, 'user_id' => 3],
['id' => 4, 'post_id' => 2, 'user_id' => 2],
['id' => 5, 'post_id' => 3, 'user_id' => 1],
['id' => 6, 'post_id' => 4, 'user_id' => 1],
['id' => 1, 'post_id' => 1, 'user_id' => 1, 'created_at' => Carbon::now()->addMinutes(2)],
['id' => 2, 'post_id' => 1, 'user_id' => 2, 'created_at' => Carbon::now()->addMinutes(3)],
['id' => 3, 'post_id' => 1, 'user_id' => 3, 'created_at' => Carbon::now()->addMinutes(4)],
['id' => 4, 'post_id' => 2, 'user_id' => 2, 'created_at' => Carbon::now()->addMinutes(5)],
['id' => 5, 'post_id' => 3, 'user_id' => 1, 'created_at' => Carbon::now()->addMinutes(6)],
['id' => 6, 'post_id' => 4, 'user_id' => 1, 'created_at' => Carbon::now()->addMinutes(7)],
]
]);
}
@ -79,12 +81,19 @@ class ListTest extends TestCase
])
);
$this->assertEquals(200, $response->getStatusCode(), $body = $response->getBody()->getContents());
$body = $response->getBody()->getContents();
$this->assertEquals(200, $response->getStatusCode(), $body);
$data = json_decode($body, true)['data'];
$ids = Arr::pluck($data, 'id');
$this->assertEqualsCanonicalizing(['1', '4', '5'], $ids);
if ($this->database() instanceof PostgresConnection) {
$this->assertEqualsCanonicalizing(['3', '4', '5'], $ids);
} else {
$this->assertEqualsCanonicalizing(['1', '4', '5'], $ids);
}
}
/**
@ -122,7 +131,7 @@ class ListTest extends TestCase
$data = json_decode($response->getBody()->getContents(), true)['data'];
$ids = Arr::pluck($data, 'id');
$this->assertEqualsCanonicalizing(['1', '4', '5'], $ids);
$this->assertCount(3, $data);
}
/**

View File

@ -18,6 +18,7 @@ use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Flarum\User\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
class ListWithTagsTest extends TestCase
{
@ -86,16 +87,16 @@ class ListWithTagsTest extends TestCase
],
Flag::class => [
// From regular ListTest
['id' => 1, 'post_id' => 1, 'user_id' => 1],
['id' => 2, 'post_id' => 1, 'user_id' => 2],
['id' => 3, 'post_id' => 1, 'user_id' => 3],
['id' => 4, 'post_id' => 2, 'user_id' => 2],
['id' => 5, 'post_id' => 3, 'user_id' => 1],
['id' => 1, 'post_id' => 1, 'user_id' => 1, 'created_at' => Carbon::now()->addMinutes(2)],
['id' => 2, 'post_id' => 1, 'user_id' => 2, 'created_at' => Carbon::now()->addMinutes(3)],
['id' => 3, 'post_id' => 1, 'user_id' => 3, 'created_at' => Carbon::now()->addMinutes(4)],
['id' => 4, 'post_id' => 2, 'user_id' => 2, 'created_at' => Carbon::now()->addMinutes(5)],
['id' => 5, 'post_id' => 3, 'user_id' => 1, 'created_at' => Carbon::now()->addMinutes(6)],
// In tags
['id' => 6, 'post_id' => 4, 'user_id' => 1],
['id' => 7, 'post_id' => 5, 'user_id' => 1],
['id' => 8, 'post_id' => 6, 'user_id' => 1],
['id' => 9, 'post_id' => 7, 'user_id' => 1],
['id' => 6, 'post_id' => 4, 'user_id' => 1, 'created_at' => Carbon::now()->addMinutes(7)],
['id' => 7, 'post_id' => 5, 'user_id' => 1, 'created_at' => Carbon::now()->addMinutes(8)],
['id' => 8, 'post_id' => 6, 'user_id' => 1, 'created_at' => Carbon::now()->addMinutes(9)],
['id' => 9, 'post_id' => 7, 'user_id' => 1, 'created_at' => Carbon::now()->addMinutes(10)],
]
]);
}
@ -111,12 +112,14 @@ class ListWithTagsTest extends TestCase
])
);
$this->assertEquals(200, $response->getStatusCode());
$body = $response->getBody()->getContents();
$data = json_decode($response->getBody()->getContents(), true)['data'];
$this->assertEquals(200, $response->getStatusCode(), $body);
$data = json_decode($body, true)['data'];
$ids = Arr::pluck($data, 'id');
$this->assertEqualsCanonicalizing(['1', '4', '5', '6', '7', '8', '9'], $ids);
$this->assertCount(7, $data);
}
/**
@ -154,7 +157,9 @@ class ListWithTagsTest extends TestCase
$data = json_decode($response->getBody()->getContents(), true)['data'];
$ids = Arr::pluck($data, 'id');
$this->assertEqualsCanonicalizing(['1', '4', '5', '8', '9'], $ids);
// 7 is included, even though mods can't view discussions.
// This is because the UI doesnt allow discussions.viewFlags without viewDiscussions.
$this->assertCount(5, $data);
}
/**

View File

@ -9,9 +9,14 @@
namespace Flarum\Flags\Tests\integration\api\posts;
use Flarum\Discussion\Discussion;
use Flarum\Flags\Flag;
use Flarum\Group\Group;
use Flarum\Post\Post;
use Flarum\Tags\Tag;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Flarum\User\User;
use Illuminate\Support\Arr;
class IncludeFlagsVisibilityTest extends TestCase
@ -28,7 +33,7 @@ class IncludeFlagsVisibilityTest extends TestCase
$this->extension('flarum-tags', 'flarum-flags');
$this->prepareDatabase([
'users' => [
User::class => [
$this->normalUser(),
[
'id' => 3,
@ -56,7 +61,7 @@ class IncludeFlagsVisibilityTest extends TestCase
['group_id' => 5, 'user_id' => 2],
['group_id' => 6, 'user_id' => 3],
],
'groups' => [
Group::class => [
['id' => 5, 'name_singular' => 'group5', 'name_plural' => 'group5', 'color' => null, 'icon' => 'fas fa-crown', 'is_hidden' => false],
['id' => 6, 'name_singular' => 'group1', 'name_plural' => 'group1', 'color' => null, 'icon' => 'fas fa-cog', 'is_hidden' => false],
],
@ -67,11 +72,11 @@ class IncludeFlagsVisibilityTest extends TestCase
['group_id' => 6, 'permission' => 'tag1.discussion.viewFlags'],
['group_id' => 6, 'permission' => 'tag1.viewForum'],
],
'tags' => [
Tag::class => [
['id' => 1, 'name' => 'Tag 1', 'slug' => 'tag-1', 'is_primary' => false, 'position' => null, 'parent_id' => null, 'is_restricted' => true],
['id' => 2, 'name' => 'Tag 2', 'slug' => 'tag-2', 'is_primary' => true, 'position' => 2, 'parent_id' => null, 'is_restricted' => false],
],
'discussions' => [
Discussion::class => [
['id' => 1, 'title' => 'Test1', 'user_id' => 1, 'comment_count' => 1],
['id' => 2, 'title' => 'Test2', 'user_id' => 1, 'comment_count' => 1],
],
@ -79,7 +84,7 @@ class IncludeFlagsVisibilityTest extends TestCase
['discussion_id' => 1, 'tag_id' => 1],
['discussion_id' => 2, 'tag_id' => 2],
],
'posts' => [
Post::class => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 2, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 3, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
@ -87,7 +92,7 @@ class IncludeFlagsVisibilityTest extends TestCase
['id' => 4, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 5, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
],
'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],

View File

@ -58,6 +58,7 @@ class PostResourceFields
// So that we can tell if the current user has liked the post.
$query
->orderBy(new Expression($grammar->wrap('user_id').' = '.$actor->id), 'desc')
->orderBy('created_at')
->limit(static::$maxLikes);
}),
];

View File

@ -54,17 +54,17 @@ class ListPostsTest extends TestCase
['id' => 112, 'username' => 'user112', 'email' => '112@machine.local', 'is_email_confirmed' => 1],
],
'post_likes' => [
['user_id' => 102, 'post_id' => 101],
['user_id' => 104, 'post_id' => 101],
['user_id' => 105, 'post_id' => 101],
['user_id' => 106, 'post_id' => 101],
['user_id' => 107, 'post_id' => 101],
['user_id' => 108, 'post_id' => 101],
['user_id' => 109, 'post_id' => 101],
['user_id' => 110, 'post_id' => 101],
['user_id' => 2, 'post_id' => 101],
['user_id' => 111, 'post_id' => 101],
['user_id' => 112, 'post_id' => 101],
['user_id' => 102, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(2)],
['user_id' => 104, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(3)],
['user_id' => 105, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(4)],
['user_id' => 106, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(5)],
['user_id' => 107, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(6)],
['user_id' => 108, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(7)],
['user_id' => 109, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(8)],
['user_id' => 110, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(9)],
['user_id' => 2, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(10)],
['user_id' => 111, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(11)],
['user_id' => 112, 'post_id' => 101, 'created_at' => Carbon::now()->addMinutes(12)],
],
'group_permission' => [
['group_id' => Group::GUEST_ID, 'permission' => 'searchUsers'],

View File

@ -12,7 +12,7 @@ namespace Flarum\Lock\Filter;
use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Eloquent\Builder;
/**
* @implements FilterInterface<DatabaseSearchState>

View File

@ -249,9 +249,11 @@ class GroupMentionsTest extends TestCase
])
);
$this->assertEquals(201, $response->getStatusCode());
$body = $response->getBody()->getContents();
$response = json_decode($response->getBody(), true);
$this->assertEquals(201, $response->getStatusCode(), $body);
$response = json_decode($body, true);
$this->assertStringNotContainsString('@Members', $response['data']['attributes']['contentHtml']);
$this->assertStringNotContainsString('@Guests', $response['data']['attributes']['contentHtml']);

View File

@ -116,35 +116,35 @@ class ListPostsTest extends TestCase
{
$this->prepareDatabase([
Discussion::class => [
['id' => 100, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 101, 'comment_count' => 12],
['id' => 100, 'title' => __CLASS__, 'created_at' => Carbon::parse('2024-05-04'), 'user_id' => 1, 'first_post_id' => 101, 'comment_count' => 12],
],
Post::class => [
['id' => 101, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 102, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 103, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>', 'is_private' => 1],
['id' => 104, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 105, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 106, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 107, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 108, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 109, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 110, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 111, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 112, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 101, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04'), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 102, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(2), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 103, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(3), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>', 'is_private' => 1],
['id' => 104, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(4), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 105, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(5), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 106, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(6), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 107, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(7), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 108, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(8), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 109, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(9), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 110, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(10), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 111, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(11), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
['id' => 112, 'discussion_id' => 100, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(12), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
],
'post_mentions_post' => [
['post_id' => 102, 'mentions_post_id' => 101],
['post_id' => 103, 'mentions_post_id' => 101],
['post_id' => 104, 'mentions_post_id' => 101],
['post_id' => 105, 'mentions_post_id' => 101],
['post_id' => 106, 'mentions_post_id' => 101],
['post_id' => 107, 'mentions_post_id' => 101],
['post_id' => 108, 'mentions_post_id' => 101],
['post_id' => 109, 'mentions_post_id' => 101],
['post_id' => 110, 'mentions_post_id' => 101],
['post_id' => 111, 'mentions_post_id' => 101],
['post_id' => 112, 'mentions_post_id' => 101],
['post_id' => 103, 'mentions_post_id' => 112],
['post_id' => 102, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(2)],
['post_id' => 103, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(3)],
['post_id' => 104, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(4)],
['post_id' => 105, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(5)],
['post_id' => 106, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(6)],
['post_id' => 107, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(7)],
['post_id' => 108, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(8)],
['post_id' => 109, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(9)],
['post_id' => 110, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(10)],
['post_id' => 111, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(11)],
['post_id' => 112, 'mentions_post_id' => 101, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(12)],
['post_id' => 103, 'mentions_post_id' => 112, 'created_at' => Carbon::parse('2024-05-04')->addMinutes(13)],
],
]);
}
@ -187,10 +187,11 @@ class ListPostsTest extends TestCase
])->withQueryParams([
'filter' => ['discussion' => 100],
'include' => 'mentionedBy',
'sort' => 'createdAt',
])
);
$data = json_decode($body = $response->getBody()->getContents(), true)['data'] ?? [];
$data = json_decode($body = $response->getBody()->getContents(), true)['data'];
$this->assertEquals(200, $response->getStatusCode(), $body);

View File

@ -51,7 +51,7 @@ class PostMentionsTest extends TestCase
['id' => 8, 'number' => 6, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '<r><POSTMENTION displayname="i_am_a_deleted_user" id="2020" number="8" discussionid="2" username="i_am_a_deleted_user">@"i_am_a_deleted_user"#p2020</POSTMENTION></r>'],
['id' => 9, 'number' => 10, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 5, 'type' => 'comment', 'content' => '<r><p>I am bad</p></r>'],
['id' => 10, 'number' => 11, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '<r><POSTMENTION displayname="Bad &quot;#p6 User" id="9" number="10" discussionid="2">@"Bad "#p6 User"#p9</POSTMENTION></r>'],
['id' => 11, 'number' => 12, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 40, 'type' => 'comment', 'content' => '<r><POSTMENTION displayname="Bad &quot;#p6 User" id="9" number="10" discussionid="2">@"Bad "#p6 User"#p9</POSTMENTION></r>'],
['id' => 11, 'number' => 12, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => null, 'type' => 'comment', 'content' => '<r><POSTMENTION displayname="Bad &quot;#p6 User" id="9" number="10" discussionid="2">@"Bad "#p6 User"#p9</POSTMENTION></r>'],
['id' => 12, 'number' => 13, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '<r><POSTMENTION displayname="deleted_user" id="11" number="12" discussionid="2">@"acme"#p11</POSTMENTION></r>'],
// Restricted access
@ -95,9 +95,11 @@ class PostMentionsTest extends TestCase
])
);
$this->assertEquals(201, $response->getStatusCode());
$body = $response->getBody()->getContents();
$response = json_decode($response->getBody(), true);
$this->assertEquals(201, $response->getStatusCode(), $body);
$response = json_decode($body, true);
$this->assertStringNotContainsString('POTATO$', $response['data']['attributes']['contentHtml']);
$this->assertEquals('@potato#4', $response['data']['attributes']['content']);
@ -191,9 +193,11 @@ class PostMentionsTest extends TestCase
])
);
$this->assertEquals(201, $response->getStatusCode());
$body = $response->getBody()->getContents();
$response = json_decode($response->getBody(), true);
$this->assertEquals(201, $response->getStatusCode(), $body);
$response = json_decode($body, true);
$this->assertStringContainsString('POTATO$', $response['data']['attributes']['contentHtml']);
$this->assertEquals('@"POTATO$"#p4', $response['data']['attributes']['content']);
@ -514,9 +518,11 @@ class PostMentionsTest extends TestCase
])
);
$this->assertEquals(200, $response->getStatusCode());
$body = $response->getBody()->getContents();
$response = json_decode($response->getBody(), true);
$this->assertEquals(200, $response->getStatusCode(), $body);
$response = json_decode($body, true);
$this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']);
$this->assertEquals('@"Bad _ User"#p9', $response['data']['attributes']['content']);

View File

@ -38,6 +38,7 @@ class UserMentionsTest extends TestCase
['id' => 3, 'username' => 'potato', 'email' => 'potato@machine.local', 'is_email_confirmed' => 1],
['id' => 4, 'username' => 'toby', 'email' => 'toby@machine.local', 'is_email_confirmed' => 1],
['id' => 5, 'username' => 'bad_user', 'email' => 'bad_user@machine.local', 'is_email_confirmed' => 1],
['id' => 50]
],
Discussion::class => [
['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 3, 'first_post_id' => 4, 'comment_count' => 2],
@ -500,9 +501,11 @@ class UserMentionsTest extends TestCase
])
);
$this->assertEquals(200, $response->getStatusCode());
$body = $response->getBody()->getContents();
$response = json_decode($response->getBody(), true);
$this->assertEquals(200, $response->getStatusCode(), $body);
$response = json_decode($body, true);
$this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']);
$this->assertEquals('@"Bad _ User"#5', $response['data']['attributes']['content']);

View File

@ -11,6 +11,7 @@ namespace Flarum\Statistics\Api\Controller;
use Carbon\Carbon;
use DateTime;
use Exception;
use Flarum\Discussion\Discussion;
use Flarum\Http\Exception\InvalidParameterException;
use Flarum\Http\RequestUtil;
@ -130,11 +131,19 @@ class ShowStatisticsData implements RequestHandlerInterface
$endDate = new DateTime();
}
$formats = match ($query->getConnection()->getDriverName()) {
'pgsql' => ['YYYY-MM-DD HH24:00:00', 'YYYY-MM-DD'],
default => ['%Y-%m-%d %H:00:00', '%Y-%m-%d'],
};
// if within the last 24 hours, group by hour
$format = 'CASE WHEN '.$column.' > ? THEN \'%Y-%m-%d %H:00:00\' ELSE \'%Y-%m-%d\' END';
$format = "CASE WHEN $column > ? THEN '$formats[0]' ELSE '$formats[1]' END";
$dbFormattedDatetime = match ($query->getConnection()->getDriverName()) {
'sqlite' => 'strftime('.$format.', '.$column.')',
default => 'DATE_FORMAT('.$column.', '.$format.')',
'sqlite' => "strftime($format, $column)",
'pgsql' => "TO_CHAR($column, $format)",
'mysql' => "DATE_FORMAT($column, $format)",
default => throw new Exception('Unsupported database driver'),
};
$results = $query

View File

@ -9,6 +9,7 @@
namespace Flarum\Sticky;
use DateTime;
use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\SearchCriteria;
use Flarum\Tags\Search\Filter\TagFilter;
@ -19,7 +20,7 @@ class PinStickiedDiscussionsToTop
public function __invoke(DatabaseSearchState $state, SearchCriteria $criteria): void
{
if ($criteria->sortIsDefault && ! $state->isFulltextSearch()) {
$query = $state->getQuery();
$query = $state->getQuery()->getQuery();
// If we are viewing a specific tag, then pin all stickied
// discussions to the top no matter what.
@ -46,6 +47,8 @@ class PinStickiedDiscussionsToTop
$sticky->where('is_sticky', true);
unset($sticky->orders);
$epochTime = (new DateTime('@0'))->format('Y-m-d H:i:s');
/** @var Builder $q */
foreach ([$sticky, $query] as $q) {
$read = $q->newQuery()
@ -58,7 +61,7 @@ class PinStickiedDiscussionsToTop
// Add the bindings manually (rather than as the second
// argument in orderByRaw) for now due to a bug in Laravel which
// would add the bindings in the wrong order.
$q->selectRaw('(is_sticky and not exists ('.$read->toSql().') and last_posted_at > ?) as is_unread_sticky', array_merge($read->getBindings(), [$state->getActor()->marked_all_as_read_at ?: 0]));
$q->selectRaw('(is_sticky and not exists ('.$read->toSql().') and last_posted_at > ?) as is_unread_sticky', array_merge($read->getBindings(), [$state->getActor()->marked_all_as_read_at ?: $epochTime]));
}
$query->union($sticky);

View File

@ -12,7 +12,7 @@ namespace Flarum\Sticky\Query;
use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Eloquent\Builder;
/**
* @implements FilterInterface<DatabaseSearchState>

View File

@ -62,11 +62,13 @@ class ListDiscussionsTest extends TestCase
$this->request('GET', '/api/discussions')
);
$this->assertEquals(200, $response->getStatusCode(), $body = $response->getBody()->getContents());
$body = $response->getBody()->getContents();
$this->assertEquals(200, $response->getStatusCode(), $body);
$data = json_decode($body, true);
$this->assertEqualsCanonicalizing([3, 1, 2, 4], Arr::pluck($data['data'], 'id'));
$this->assertEquals([3, 1, 2, 4], Arr::pluck($data['data'], 'id'));
}
/** @test */
@ -114,10 +116,12 @@ class ListDiscussionsTest extends TestCase
])
);
$this->assertEquals(200, $response->getStatusCode(), $body = $response->getBody()->getContents());
$body = $response->getBody()->getContents();
$this->assertEquals(200, $response->getStatusCode(), $body);
$data = json_decode($body, true);
$this->assertEqualsCanonicalizing([3, 1, 2, 4], Arr::pluck($data['data'], 'id'));
$this->assertEquals([3, 1, 2, 4], Arr::pluck($data['data'], 'id'));
}
}

View File

@ -10,8 +10,12 @@
namespace Flarum\Sticky\Tests\integration\api;
use Carbon\Carbon;
use Flarum\Discussion\Discussion;
use Flarum\Group\Group;
use Flarum\Post\Post;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Flarum\User\User;
class StickyDiscussionsTest extends TestCase
{
@ -24,18 +28,24 @@ class StickyDiscussionsTest extends TestCase
$this->extension('flarum-sticky');
$this->prepareDatabase([
'users' => [
User::class => [
['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1],
$this->normalUser(),
['id' => 3, 'username' => 'Muralf_', 'email' => 'muralf_@machine.local', 'is_email_confirmed' => 1],
],
'discussions' => [
Discussion::class => [
['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'is_sticky' => true, 'last_post_number' => 1],
['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now()->addMinutes(2), 'last_posted_at' => Carbon::now()->addMinutes(5), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'is_sticky' => false, 'last_post_number' => 1],
['id' => 3, 'title' => __CLASS__, 'created_at' => Carbon::now()->addMinutes(3), 'last_posted_at' => Carbon::now()->addMinute(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'is_sticky' => true, 'last_post_number' => 1],
['id' => 4, 'title' => __CLASS__, 'created_at' => Carbon::now()->addMinutes(4), 'last_posted_at' => Carbon::now()->addMinutes(2), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'is_sticky' => false, 'last_post_number' => 1],
['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now()->addMinutes(2), 'last_posted_at' => Carbon::now()->addMinutes(5), 'user_id' => 1, 'first_post_id' => 2, 'comment_count' => 1, 'is_sticky' => false, 'last_post_number' => 1],
['id' => 3, 'title' => __CLASS__, 'created_at' => Carbon::now()->addMinutes(3), 'last_posted_at' => Carbon::now()->addMinute(), 'user_id' => 1, 'first_post_id' => 3, 'comment_count' => 1, 'is_sticky' => true, 'last_post_number' => 1],
['id' => 4, 'title' => __CLASS__, 'created_at' => Carbon::now()->addMinutes(4), 'last_posted_at' => Carbon::now()->addMinutes(2), 'user_id' => 1, 'first_post_id' => 4, 'comment_count' => 1, 'is_sticky' => false, 'last_post_number' => 1],
],
'groups' => [
Post::class => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'number' => 1],
['id' => 2, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'number' => 1],
['id' => 3, 'discussion_id' => 3, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'number' => 1],
['id' => 4, 'discussion_id' => 4, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'number' => 1],
],
Group::class => [
['id' => 5, 'name_singular' => 'Group', 'name_plural' => 'Groups', 'color' => 'blue'],
],
'group_user' => [

View File

@ -14,7 +14,7 @@ use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Flarum\Search\ValidateFilterTrait;
use Flarum\User\User;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Eloquent\Builder;
/**
* @implements FilterInterface<DatabaseSearchState>

View File

@ -41,15 +41,15 @@ class ReplyNotificationTest extends TestCase
['id' => 33, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 33, 'comment_count' => 6, 'last_post_number' => 6, 'last_post_id' => 38],
],
Post::class => [
['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 1],
['id' => 2, 'discussion_id' => 2, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 1],
['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(1)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 1],
['id' => 2, 'discussion_id' => 2, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(2)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 1],
['id' => 33, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 1],
['id' => 34, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 2],
['id' => 35, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 3],
['id' => 36, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 4],
['id' => 37, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 5],
['id' => 38, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 6],
['id' => 33, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(3)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 1],
['id' => 34, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(4)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 2],
['id' => 35, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(5)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 3],
['id' => 36, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(6)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 4],
['id' => 37, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(7)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 5],
['id' => 38, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(8)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 6],
],
'discussion_user' => [
['discussion_id' => 1, 'user_id' => 1, 'last_read_post_number' => 1, 'subscription' => 'follow'],

View File

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

View File

@ -15,7 +15,7 @@ use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Flarum\User\Guest;
use Flarum\User\UserRepository;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Eloquent\Builder;
/**
* @implements FilterInterface<DatabaseSearchState>

View File

@ -16,8 +16,9 @@ use Flarum\Search\SearchState;
use Flarum\Search\ValidateFilterTrait;
use Flarum\Tags\Tag;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
/**
* @implements FilterInterface<DatabaseSearchState>
@ -53,7 +54,7 @@ class TagFilter implements FilterInterface
$query->where(function (Builder $query) use ($slugs, $negate, $actor) {
foreach ($slugs as $slug) {
if ($slug === 'untagged') {
$query->whereIn('discussions.id', function (Builder $query) {
$query->whereIn('discussions.id', function (QueryBuilder $query) {
$query->select('discussion_id')
->from('discussion_tag');
}, 'or', ! $negate);
@ -65,7 +66,7 @@ class TagFilter implements FilterInterface
$id = null;
}
$query->whereIn('discussions.id', function (Builder $query) use ($id) {
$query->whereIn('discussions.id', function (QueryBuilder $query) use ($id) {
$query->select('discussion_id')
->from('discussion_tag')
->where('tag_id', $id);

View File

@ -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);

View File

@ -32,11 +32,18 @@ export type Extension = {
extra: {
'flarum-extension': {
title: string;
'database-support': undefined | string[];
};
};
require?: Record<string, string>;
};
export enum DatabaseDriver {
MySQL = 'MySQL',
PostgreSQL = 'PostgreSQL',
SQLite = 'SQLite',
}
export interface AdminApplicationData extends ApplicationData {
extensions: Record<string, Extension>;
settings: Record<string, string>;
@ -48,6 +55,14 @@ export interface AdminApplicationData extends ApplicationData {
maintenanceByConfig: boolean;
safeModeExtensions?: string[] | null;
safeModeExtensionsConfig?: string[] | null;
dbDriver: DatabaseDriver;
dbVersion: string;
dbOptions: Record<string, string>;
phpVersion: string;
queueDriver: string;
schedulerStatus: string;
sessionDriver: string;
}
export default class AdminApplication extends Application {

View File

@ -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<CustomAttrs extends IPageAttrs = IPageAttrs> extends AdminPage<CustomAttrs> {
searchDriverOptions: Record<string, Record<string, string>> = {};
@ -68,6 +69,10 @@ export default class AdvancedPage<CustomAttrs extends IPageAttrs = IPageAttrs> 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<CustomAttrs extends IPageAttrs = IPageAttrs> e
</FormSection>
);
}
pgsqlSettings() {
return (
<FormSection label={DatabaseDriver.PostgreSQL}>
<Form>
{this.buildSettingComponent({
type: 'select',
setting: 'pgsql_search_configuration',
options: app.data.dbOptions.search_configurations,
label: app.translator.trans('core.admin.advanced.pgsql.search_configuration'),
})}
</Form>
</FormSection>
);
}
}

View File

@ -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<Attrs extends ExtensionPageAttrs = ExtensionP
}
body(vnode: Mithril.VnodeDOM<Attrs, this>) {
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() ? (
<div className="ExtensionPage-body">{this.sections(vnode).toArray()}</div>
<div className="ExtensionPage-body">
{!supportsDbDriver && (
<Alert type="error" dismissible={false}>
{app.translator.trans('core.admin.extension.database_driver_mismatch')}
</Alert>
)}
{this.sections(vnode).toArray()}
</div>
) : (
<div className="container">
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.enable_to_see')}</h3>
@ -187,7 +199,6 @@ export default class ExtensionPage<Attrs extends ExtensionPageAttrs = ExtensionP
}
};
// TODO v2.0: rename `uninstall` to `purge`
items.add(
'uninstall',
<Button icon="fas fa-trash-alt" className="Button Button--primary" onclick={purge.bind(this)}>
@ -225,6 +236,27 @@ export default class ExtensionPage<Attrs extends ExtensionPageAttrs = ExtensionP
}
});
let supportedDatabases = this.extension.extra['flarum-extension']['database-support'] ?? null;
if (supportedDatabases && supportedDatabases.length) {
supportedDatabases = supportedDatabases.map((database: string) => {
return (
{
mysql: 'MySQL',
sqlite: 'SQLite',
pgsql: 'PostgreSQL',
}[database] || database
);
});
items.add(
'database-support',
<span className="LinkButton">
<Icon name="fas fa-database" />
{supportedDatabases.join(', ')}
</span>
);
}
const extension = this.extension;
items.add(
'readme',

View File

@ -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');

View File

@ -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 <code>safe_mode_extensions</code> key in your <code>config.php</code> file. (<b>{extensions}</b>)"
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."

View File

@ -32,11 +32,10 @@ return [
$table->unique(['discussion_id', 'number']);
});
$connection = $schema->getConnection();
$prefix = $connection->getTablePrefix();
if ($connection->getDriverName() !== 'sqlite') {
$connection->statement('ALTER TABLE '.$prefix.'posts ADD FULLTEXT content (content)');
if ($schema->getConnection()->getDriverName() !== 'sqlite') {
$schema->table('posts', function (Blueprint $table) {
$table->fullText('content');
});
}
},

View File

@ -26,11 +26,22 @@ return [
$table->integer('user_id')->unsigned()->change();
});
// Use a separate schema instance because this column gets renamed
// in the previous one.
$schema->table('access_tokens', function (Blueprint $table) {
$table->dateTime('last_activity_at')->change();
});
if ($schema->getConnection()->getDriverName() === 'pgsql') {
$prefix = $schema->getConnection()->getTablePrefix();
// Changing an integer col to datetime is an unusual operation in PostgreSQL.
$schema->getConnection()->statement(<<<SQL
ALTER TABLE {$prefix}access_tokens
ALTER COLUMN last_activity_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE
USING to_timestamp(last_activity_at)
SQL);
} else {
// 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();
});
}
},
'down' => function (Builder $schema) {

View File

@ -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');
});
}
}
];

View File

@ -28,6 +28,13 @@ return [
$db->table('groups')->insert(array_combine(['id', 'name_singular', 'name_plural', 'color', 'icon'], $group));
}
// PgSQL doesn't auto-increment the sequence when inserting the IDs manually.
if ($db->getDriverName() === 'pgsql') {
$table = $db->getSchemaGrammar()->wrapTable('groups');
$seq = $db->getSchemaGrammar()->wrapTable('groups_id_seq');
$db->statement("SELECT setval('$seq', (SELECT MAX(id) FROM $table))");
}
},
'down' => function (Builder $schema) {

View File

@ -0,0 +1,37 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => 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();
});
}
}
];

View File

@ -0,0 +1,37 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => 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();
});
}
}
];

File diff suppressed because it is too large Load Diff

View File

@ -62,6 +62,7 @@ class AdminPayload
$document->payload['phpVersion'] = $this->appInfo->identifyPHPVersion();
$document->payload['dbDriver'] = $this->appInfo->identifyDatabaseDriver();
$document->payload['dbVersion'] = $this->appInfo->identifyDatabaseVersion();
$document->payload['dbOptions'] = $this->appInfo->identifyDatabaseOptions();
$document->payload['debugEnabled'] = Arr::get($this->config, 'debug');
if ($this->appInfo->scheduledTasksRegistered()) {

View File

@ -217,7 +217,11 @@ class UserResource extends AbstractDatabaseResource
|| $context->getActor()->can('editCredentials', $user);
})
->set(function (User $user, ?string $value) {
$user->exists && $user->changePassword($value);
if ($user->exists) {
$user->changePassword($value);
} else {
$user->password = $value;
}
}),
// Registration token.
Schema\Str::make('token')

View File

@ -18,7 +18,9 @@ use Illuminate\Contracts\Container\Container;
use Illuminate\Database\Capsule\Manager;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\ConnectionResolverInterface;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Support\Str;
class DatabaseServiceProvider extends AbstractServiceProvider
@ -28,6 +30,7 @@ class DatabaseServiceProvider extends AbstractServiceProvider
public function register(): void
{
$this->registerEloquentFactory();
$this->registerBuilderMacros();
$this->container->singleton(Manager::class, function (ContainerImplementation $container) {
$manager = new Manager($container);
@ -78,6 +81,41 @@ class DatabaseServiceProvider extends AbstractServiceProvider
});
}
protected function registerBuilderMacros(): void
{
$drivers = [
'mysql' => 'MySql',
'pgsql' => 'PgSql',
'sqlite' => 'Sqlite',
];
foreach ([QueryBuilder::class, EloquentBuilder::class] as $builder) {
foreach ($drivers as $driver => $macro) {
$builder::macro('when'.$macro, function ($callback, $else) use ($driver) {
/** @var QueryBuilder|EloquentBuilder $this */
if ($this->getConnection()->getDriverName() === $driver) {
$callback($this);
} else {
$else($this);
}
return $this;
});
$builder::macro('unless'.$macro, function ($callback, $else) use ($driver) {
/** @var QueryBuilder|EloquentBuilder $this */
if ($this->getConnection()->getDriverName() !== $driver) {
$callback($this);
} else {
$else($this);
}
return $this;
});
}
}
}
protected function registerEloquentFactory(): void
{
$this->app->singleton(FakerGenerator::class, function ($app, $parameters) {

View File

@ -69,8 +69,38 @@ class Migrator
// Once we have the array of migrations, we will spin through them and run the
// migrations "up" so the changes are made to the databases. We'll then log
// that the migration was run so we don't repeat it next time we execute.
foreach ($migrations as $file) {
$this->runUp($path, $file, $extension);
$this->runUpMigrations($migrations, $path, $extension);
}
protected function runUpMigrations(array $migrations, string $path, ?Extension $extension = null): void
{
$process = function () use ($migrations, $path, $extension) {
foreach ($migrations as $migration) {
$this->runUp($path, $migration, $extension);
}
};
// PgSQL allows DDL statements in transactions.
if ($this->connection->getDriverName() === 'pgsql') {
$this->connection->transaction($process);
} else {
$process();
}
}
protected function runDownMigrations(array $migrations, string $path, ?Extension $extension = null): void
{
$process = function () use ($migrations, $path, $extension) {
foreach ($migrations as $migration) {
$this->runDown($path, $migration, $extension);
}
};
// PgSQL allows DDL statements in transactions.
if ($this->connection->getDriverName() === 'pgsql') {
$this->connection->transaction($process);
} else {
$process();
}
}
@ -103,9 +133,7 @@ class Migrator
if ($count === 0) {
$this->note('<info>Nothing to rollback.</info>');
} else {
foreach ($migrations as $migration) {
$this->runDown($path, $migration, $extension);
}
$this->runDownMigrations($migrations, $path, $extension);
}
return $count;
@ -221,9 +249,11 @@ class Migrator
$dump = file_get_contents($schemaPath);
$dumpWithoutComments = preg_replace('/^--.*$/m', '', $dump);
$this->connection->getSchemaBuilder()->disableForeignKeyConstraints();
foreach (explode(';', $dump) as $statement) {
foreach (explode(';', $dumpWithoutComments) as $statement) {
$statement = trim($statement);
if (empty($statement) || str_starts_with($statement, '/*')) {
@ -238,6 +268,10 @@ class Migrator
$this->connection->statement($statement);
}
if ($driver === 'pgsql') {
$this->connection->statement('SELECT pg_catalog.set_config(\'search_path\', \'public\', false)');
}
$this->connection->getSchemaBuilder()->enableForeignKeyConstraints();
$runTime = number_format((microtime(true) - $startTime) * 1000, 2);

View File

@ -183,7 +183,7 @@ class Discussion extends AbstractModel
public function refreshLastPost(): static
{
if ($lastPost = $this->comments()->latest()->first()) {
if ($lastPost = $this->comments()->latest()->latest('id')->first()) {
/** @var Post $lastPost */
$this->setLastPost($lastPost);
}

View File

@ -14,7 +14,7 @@ use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Flarum\Search\ValidateFilterTrait;
use Flarum\User\UserRepository;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Eloquent\Builder;
/**
* @implements FilterInterface<DatabaseSearchState>

View File

@ -13,7 +13,7 @@ use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Flarum\Search\ValidateFilterTrait;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
/**
@ -40,7 +40,7 @@ class CreatedFilter implements FilterInterface
$this->constrain($state->getQuery(), $from, $to, $negate);
}
public function constrain(Builder $query, ?string $from, ?string $to, bool $negate): void
protected function constrain(Builder $query, ?string $from, ?string $to, bool $negate): void
{
// If we've just been provided with a single YYYY-MM-DD date, then find
// discussions that were started on that exact date. But if we've been

View File

@ -12,7 +12,7 @@ namespace Flarum\Discussion\Search\Filter;
use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Eloquent\Builder;
/**
* @implements FilterInterface<DatabaseSearchState>

View File

@ -14,7 +14,7 @@ use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Flarum\User\User;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Eloquent\Builder;
/**
* @implements FilterInterface<DatabaseSearchState>
@ -41,11 +41,15 @@ class UnreadFilter implements FilterInterface
if ($actor->exists) {
$readIds = $this->discussions->getReadIdsQuery($actor);
$query->where(function ($query) use ($readIds, $negate, $actor) {
$query->where(function (Builder $query) use ($readIds, $negate, $actor) {
if (! $negate) {
$query->whereNotIn('id', $readIds)->where('last_posted_at', '>', $actor->marked_all_as_read_at ?: 0);
$query->whereNotIn('id', $readIds)->when($actor->marked_all_as_read_at, function (Builder $query) use ($actor) {
$query->where('last_posted_at', '>', $actor->marked_all_as_read_at);
});
} else {
$query->whereIn('id', $readIds)->orWhere('last_posted_at', '<=', $actor->marked_all_as_read_at ?: 0);
$query->whereIn('id', $readIds)->when($actor->marked_all_as_read_at, function (Builder $query) use ($actor) {
$query->orWhere('last_posted_at', '<=', $actor->marked_all_as_read_at);
});
}
});
}

View File

@ -14,37 +14,57 @@ use Flarum\Post\Post;
use Flarum\Search\AbstractFulltextFilter;
use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\SearchState;
use Illuminate\Database\Query\Builder;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Query\Expression;
use RuntimeException;
/**
* @extends AbstractFulltextFilter<DatabaseSearchState>
*/
class FulltextFilter extends AbstractFulltextFilter
{
public function __construct(
protected SettingsRepositoryInterface $settings
) {
}
public function search(SearchState $state, string $value): void
{
match ($state->getQuery()->getConnection()->getDriverName()) {
'mysql' => $this->mysql($state, $value),
'pgsql' => $this->pgsql($state, $value),
'sqlite' => $this->sqlite($state, $value),
default => throw new RuntimeException('Unsupported database driver: '.$state->getQuery()->getConnection()->getDriverName()),
};
}
protected function sqlite(DatabaseSearchState $state, string $value): void
{
/** @var Builder $query */
$query = $state->getQuery();
if ($query->getConnection()->getDriverName() === 'sqlite') {
$query->where(function (Builder $query) use ($state, $value) {
$query->where('discussions.title', 'like', "%$value%")
->orWhereExists(function (Builder $query) use ($state, $value) {
$query->selectRaw('1')
->from(
Post::whereVisibleTo($state->getActor())
->whereColumn('discussion_id', 'discussions.id')
->where('type', 'comment')
->where('content', 'like', "%$value%")
->limit(1)
->toBase()
);
});
});
$query->where(function (Builder $query) use ($state, $value) {
$query->where('discussions.title', 'like', "%$value%")
->orWhereExists(function (QueryBuilder $query) use ($state, $value) {
$query->selectRaw('1')
->from(
Post::whereVisibleTo($state->getActor())
->whereColumn('discussion_id', 'discussions.id')
->where('type', 'comment')
->where('content', 'like', "%$value%")
->limit(1)
->toBase()
);
});
});
}
return;
}
protected function mysql(DatabaseSearchState $state, string $value): void
{
/** @var Builder $query */
$query = $state->getQuery();
// Replace all non-word characters with spaces.
// We do this to prevent MySQL fulltext search boolean mode from taking
@ -53,10 +73,15 @@ class FulltextFilter extends AbstractFulltextFilter
$grammar = $query->getGrammar();
$match = 'MATCH('.$grammar->wrap('posts.content').') AGAINST (?)';
$matchBooleanMode = 'MATCH('.$grammar->wrap('posts.content').') AGAINST (? IN BOOLEAN MODE)';
$matchTitle = 'MATCH('.$grammar->wrap('discussions.title').') AGAINST (?)';
$mostRelevantPostId = 'SUBSTRING_INDEX(GROUP_CONCAT('.$grammar->wrap('posts.id').' ORDER BY '.$match.' DESC, '.$grammar->wrap('posts.number').'), \',\', 1) as most_relevant_post_id';
$discussionSubquery = Discussion::select('id')
->selectRaw('NULL as score')
->selectRaw('first_post_id as most_relevant_post_id')
->whereRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (? IN BOOLEAN MODE)', [$value]);
->whereRaw($matchTitle, [$value]);
// Construct a subquery to fetch discussions which contain relevant
// posts. Retrieve the collective relevance of each discussion's posts,
@ -64,10 +89,10 @@ class FulltextFilter extends AbstractFulltextFilter
// the ID of the most relevant post.
$subquery = Post::whereVisibleTo($state->getActor())
->select('posts.discussion_id')
->selectRaw('SUM(MATCH('.$grammar->wrap('posts.content').') AGAINST (?)) as score', [$value])
->selectRaw('SUBSTRING_INDEX(GROUP_CONCAT('.$grammar->wrap('posts.id').' ORDER BY MATCH('.$grammar->wrap('posts.content').') AGAINST (?) DESC, '.$grammar->wrap('posts.number').'), \',\', 1) as most_relevant_post_id', [$value])
->selectRaw("SUM($match) as score", [$value])
->selectRaw($mostRelevantPostId, [$value])
->where('posts.type', 'comment')
->whereRaw('MATCH('.$grammar->wrap('posts.content').') AGAINST (? IN BOOLEAN MODE)', [$value])
->whereRaw($matchBooleanMode, [$value])
->groupBy('posts.discussion_id')
->union($discussionSubquery);
@ -84,9 +109,71 @@ class FulltextFilter extends AbstractFulltextFilter
->groupBy('discussions.id')
->addBinding($subquery->getBindings(), 'join');
$state->setDefaultSort(function (Builder $query) use ($grammar, $value) {
$query->orderByRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (?) desc', [$value]);
$state->setDefaultSort(function (Builder $query) use ($value, $matchTitle) {
$query->orderByRaw("$matchTitle desc", [$value]);
$query->orderBy('posts_ft.score', 'desc');
});
}
protected function pgsql(DatabaseSearchState $state, string $value): void
{
$searchConfig = $this->settings->get('pgsql_search_configuration');
/** @var Builder $query */
$query = $state->getQuery();
$grammar = $query->getGrammar();
$matchCondition = "to_tsvector('$searchConfig', ".$grammar->wrap('posts.content').") @@ plainto_tsquery('$searchConfig', ?)";
$matchScore = "ts_rank(to_tsvector('$searchConfig', ".$grammar->wrap('posts.content')."), plainto_tsquery('$searchConfig', ?))";
$matchTitleCondition = "to_tsvector('$searchConfig', ".$grammar->wrap('discussions.title').") @@ plainto_tsquery('$searchConfig', ?)";
$matchTitleScore = "ts_rank(to_tsvector('$searchConfig', ".$grammar->wrap('discussions.title')."), plainto_tsquery('$searchConfig', ?))";
$mostRelevantPostId = 'CAST(SPLIT_PART(STRING_AGG(CAST('.$grammar->wrap('posts.id')." AS VARCHAR), ',' ORDER BY ".$matchScore.' DESC, '.$grammar->wrap('posts.number')."), ',', 1) AS INTEGER) as most_relevant_post_id";
$discussionSubquery = Discussion::select('id')
->selectRaw('NULL as score')
->selectRaw('first_post_id as most_relevant_post_id')
->whereRaw($matchTitleCondition, [$value]);
// Construct a subquery to fetch discussions which contain relevant
// posts. Retrieve the collective relevance of each discussion's posts,
// which we will use later in the order by clause, and also retrieve
// the ID of the most relevant post.
$subquery = Post::whereVisibleTo($state->getActor())
->select('posts.discussion_id')
->selectRaw("SUM($matchScore) as score", [$value])
->selectRaw($mostRelevantPostId, [$value])
->where('posts.type', 'comment')
->whereRaw($matchCondition, [$value])
->groupBy('posts.discussion_id')
->union($discussionSubquery);
// Join the subquery into the main search query and scope results to
// discussions that have a relevant title or that contain relevant posts.
$query
->distinct('discussions.id')
->addSelect('posts_ft.most_relevant_post_id')
->addSelect('posts_ft.score')
->join(
new Expression('('.$subquery->toSql().') '.$grammar->wrapTable('posts_ft')),
'posts_ft.discussion_id',
'=',
'discussions.id'
)
->addBinding($subquery->getBindings(), 'join')
->orderBy('discussions.id');
$state->setQuery(
$query
->getModel()
->newQuery()
->select('*')
->fromSub($query, 'discussions')
);
$state->setDefaultSort(function (Builder $query) use ($value, $matchTitleScore) {
$query->orderByRaw("$matchTitleScore desc", [$value]);
$query->orderBy('discussions.score', 'desc');
});
}
}

View File

@ -44,6 +44,8 @@ class UserState extends AbstractModel
'last_read_at' => 'datetime'
];
public $incrementing = false;
/**
* The attributes that are mass assignable.
*/

View File

@ -71,7 +71,7 @@ class ApplicationInfoProvider
public function identifyDatabaseVersion(): string
{
return match ($this->config['database.driver']) {
'mysql' => $this->db->selectOne('select version() as version')->version,
'mysql', 'pgsql' => $this->db->selectOne('select version() as version')->version,
'sqlite' => $this->db->selectOne('select sqlite_version() as version')->version,
default => 'Unknown',
};
@ -81,11 +81,26 @@ class ApplicationInfoProvider
{
return match ($this->config['database.driver']) {
'mysql' => 'MySQL',
'pgsql' => 'PostgreSQL',
'sqlite' => 'SQLite',
default => $this->config['database.driver'],
};
}
public function identifyDatabaseOptions(): array
{
if ($this->config['database.driver'] === 'pgsql') {
return [
'search_configurations' => collect($this->db->select('SELECT * FROM pg_ts_config'))
->pluck('cfgname')
->mapWithKeys(fn (string $cfgname) => [$cfgname => $cfgname])
->toArray(),
];
}
return [];
}
/**
* Reports on the session driver in use based on three scenarios:
* 1. If the configured session driver is valid and in use, it will be returned.

View File

@ -0,0 +1,26 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Foundation\ErrorHandling\ExceptionHandler;
use Flarum\Foundation\ErrorHandling\HandledError;
use Illuminate\Database\QueryException;
class QueryExceptionHandler
{
public function handle(QueryException $e): HandledError
{
return (new HandledError(
$e,
'db_error',
500,
true
))->withDetails([]);
}
}

View File

@ -30,7 +30,8 @@ class HandledError
public function __construct(
private readonly Throwable $error,
private readonly string $type,
private readonly int $statusCode
private readonly int $statusCode,
private bool $report = false
) {
}
@ -58,7 +59,7 @@ class HandledError
public function shouldBeReported(): bool
{
return $this->type === 'unknown';
return $this->type === 'unknown' || $this->report;
}
public function getDetails(): array

View File

@ -13,6 +13,7 @@ use Flarum\Extension\Exception as ExtensionException;
use Flarum\Foundation\ErrorHandling as Handling;
use Flarum\Http\Exception\InvalidParameterException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\QueryException;
use Illuminate\Validation\ValidationException as IlluminateValidationException;
use Tobyz\JsonApiServer\Exception as TobyzJsonApiServerException;
@ -65,6 +66,7 @@ class ErrorServiceProvider extends AbstractServiceProvider
ExtensionException\CircularDependenciesException::class => ExtensionException\CircularDependenciesExceptionHandler::class,
ExtensionException\DependentExtensionsException::class => ExtensionException\DependentExtensionsExceptionHandler::class,
ExtensionException\MissingDependenciesException::class => ExtensionException\MissingDependenciesExceptionHandler::class,
QueryException::class => Handling\ExceptionHandler\QueryExceptionHandler::class,
TobyzJsonApiServerException\ErrorProvider::class => Handling\ExceptionHandler\JsonApiExceptionHandler::class,
];
});

View File

@ -26,6 +26,8 @@ class Permission extends AbstractModel
'created_at' => 'datetime'
];
public $incrementing = false;
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);

View File

@ -42,20 +42,31 @@ class UserDataProvider implements DataProviderInterface
private function getDatabaseConfiguration(): DatabaseConfig
{
$host = $this->ask('Database host (required):');
$port = 3306;
$driver = $this->ask('Database driver (mysql, sqlite, pgsql) (Default: mysql):', 'mysql');
$port = match ($driver) {
'mysql' => 3306,
'pgsql' => 5432,
default => 0,
};
if (Str::contains($host, ':')) {
list($host, $port) = explode(':', $host, 2);
if (in_array($driver, ['mysql', 'pgsql'])) {
$host = $this->ask('Database host (required):');
if (Str::contains($host, ':')) {
list($host, $port) = explode(':', $host, 2);
}
$user = $this->ask('Database user (required):');
$password = $this->secret('Database password:');
}
return new DatabaseConfig(
$this->ask('Database driver (mysql, sqlite) (Default: mysql):', 'mysql'),
$host,
$driver,
$host ?? null,
intval($port),
$this->ask('Database name (required):'),
$this->ask('Database user (required):'),
$this->secret('Database password:'),
$user ?? null,
$password ?? null,
$this->ask('Prefix:')
);
}

View File

@ -76,20 +76,25 @@ class InstallController implements RequestHandlerInterface
private function makeDatabaseConfig(array $input): DatabaseConfig
{
$host = Arr::get($input, 'mysqlHost');
$port = 3306;
$driver = Arr::get($input, 'dbDriver');
$host = Arr::get($input, 'dbHost');
$port = match ($driver) {
'mysql' => 3306,
'pgsql' => 5432,
default => 0,
};
if (Str::contains($host, ':')) {
list($host, $port) = explode(':', $host, 2);
}
return new DatabaseConfig(
Arr::get($input, 'dbDriver'),
$driver,
$host,
intval($port),
Arr::get($input, 'dbName'),
Arr::get($input, 'mysqlUsername'),
Arr::get($input, 'mysqlPassword'),
Arr::get($input, 'dbUsername'),
Arr::get($input, 'dbPassword'),
Arr::get($input, 'tablePrefix')
);
}

View File

@ -16,11 +16,11 @@ class DatabaseConfig implements Arrayable
{
public function __construct(
private readonly string $driver,
private readonly string $host,
private readonly ?string $host,
private readonly int $port,
private string $database,
private readonly string $username,
private readonly string $password,
private readonly ?string $username,
private readonly ?string $password,
private readonly string $prefix
) {
$this->validate();
@ -42,15 +42,15 @@ class DatabaseConfig implements Arrayable
throw new ValidationFailed('Please specify a database driver.');
}
if (! in_array($this->driver, ['mysql', 'sqlite'])) {
if (! in_array($this->driver, ['mysql', 'sqlite', 'pgsql'])) {
throw new ValidationFailed('Currently, only MySQL/MariaDB and SQLite are supported.');
}
if ($this->driver === 'mysql' && empty($this->host)) {
if (in_array($this->driver, ['mysql', 'pgsql']) && empty($this->host)) {
throw new ValidationFailed('Please specify the hostname of your database server.');
}
if ($this->driver === 'mysql' && ($this->port < 1 || $this->port > 65535)) {
if (in_array($this->driver, ['mysql', 'pgsql']) && ($this->port < 1 || $this->port > 65535)) {
throw new ValidationFailed('Please provide a valid port number between 1 and 65535.');
}
@ -58,7 +58,7 @@ class DatabaseConfig implements Arrayable
throw new ValidationFailed('Please specify the database name.');
}
if ($this->driver === 'mysql' && empty($this->username)) {
if (in_array($this->driver, ['mysql', 'pgsql']) && empty($this->username)) {
throw new ValidationFailed('Please specify the username for accessing the database.');
}
@ -94,6 +94,15 @@ class DatabaseConfig implements Arrayable
'engine' => 'InnoDB',
'strict' => false,
],
'pgsql' => [
'host' => $this->host,
'port' => $this->port,
'username' => $this->username,
'password' => $this->password,
'charset' => 'utf8',
'search_path' => 'public',
'sslmode' => 'prefer',
],
'sqlite' => [
'foreign_key_constraints' => true,
],

View File

@ -13,8 +13,10 @@ use Closure;
use Flarum\Install\DatabaseConfig;
use Flarum\Install\Step;
use Illuminate\Database\Connectors\MySqlConnector;
use Illuminate\Database\Connectors\PostgresConnector;
use Illuminate\Database\Connectors\SQLiteConnector;
use Illuminate\Database\MySqlConnection;
use Illuminate\Database\PostgresConnection;
use Illuminate\Database\SQLiteConnection;
use Illuminate\Support\Str;
use InvalidArgumentException;
@ -40,6 +42,7 @@ class ConnectToDatabase implements Step
match ($config['driver']) {
'mysql' => $this->mysql($config),
'pgsql' => $this->pgsql($config),
'sqlite' => $this->sqlite($config),
default => throw new InvalidArgumentException('Unsupported database driver: '.$config['driver']),
};
@ -53,11 +56,11 @@ class ConnectToDatabase implements Step
if (Str::contains($version, 'MariaDB')) {
if (version_compare($version, '10.10.0', '<')) {
throw new RangeException('MariaDB version too low. You need at least MariaDB 10.0.5');
throw new RangeException("MariaDB version ($version) too low. You need at least MariaDB 10.10");
}
} else {
if (version_compare($version, '5.7.0', '<')) {
throw new RangeException('MySQL version too low. You need at least MySQL 5.7');
throw new RangeException("MySQL version ($version) too low. You need at least MySQL 5.7");
}
}
@ -71,6 +74,27 @@ class ConnectToDatabase implements Step
);
}
private function pgsql(array $config): void
{
$pdo = (new PostgresConnector)->connect($config);
$version = $pdo->query('SHOW server_version')->fetchColumn();
$version = Str::before($version, ' ');
if (version_compare($version, '9.5.0', '<')) {
throw new RangeException("PostgreSQL version ($version) too low. You need at least PostgreSQL 9.5");
}
($this->store)(
new PostgresConnection(
$pdo,
$config['database'],
$config['prefix'],
$config
)
);
}
private function sqlite(array $config): void
{
if (! file_exists($config['database'])) {
@ -81,8 +105,8 @@ class ConnectToDatabase implements Step
$version = $pdo->query('SELECT sqlite_version()')->fetchColumn();
if (version_compare($version, '3.8.8', '<')) {
throw new RangeException('SQLite version too low. You need at least SQLite 3.8.8');
if (version_compare($version, '3.35.0', '<')) {
throw new RangeException("SQLite version ($version) too low. You need at least SQLite 3.35.0");
}
($this->store)(

View File

@ -24,7 +24,7 @@ use League\Flysystem\Local\LocalFilesystemAdapter;
class EnableBundledExtensions implements Step
{
public const EXTENSION_WHITELIST = [
public const DEFAULT_ENABLED_EXTENSIONS = [
'flarum-approval',
'flarum-bbcode',
'flarum-emoji',
@ -54,7 +54,7 @@ class EnableBundledExtensions implements Step
private readonly string $assetPath,
?array $enabledExtensions = null
) {
$this->enabledExtensions = $enabledExtensions ?? self::EXTENSION_WHITELIST;
$this->enabledExtensions = $enabledExtensions ?? self::DEFAULT_ENABLED_EXTENSIONS;
}
public function getMessage(): string

View File

@ -159,7 +159,17 @@ class Notification extends AbstractModel
*/
public function scopeMatchingBlueprint(Builder $query, BlueprintInterface $blueprint): Builder
{
return $query->where(static::getBlueprintAttributes($blueprint));
$attributes = static::getBlueprintAttributes($blueprint);
$data = $attributes['data'];
unset($attributes['data']);
return $query->where($attributes)
->whenPgSql(function ($query) use ($data) {
return $query->whereRaw('data::text = ?', [$data]);
}, function ($query) use ($data) {
return $query->where('data', $data);
});
}
/**

View File

@ -31,7 +31,7 @@ class NotificationRepository
{
$primaries = Notification::query()
->selectRaw('MAX(id) AS id')
->selectRaw('SUM(read_at IS NULL) AS unread_count')
->selectRaw('COUNT(read_at IS NULL) AS unread_count')
->where('user_id', $user->id)
->whereIn('type', $user->getAlertableNotificationTypes())
->where('is_deleted', false)

View File

@ -33,7 +33,7 @@ abstract class AbstractSearcher implements SearcherInterface
$query = $this->getQuery($actor);
$search = new DatabaseSearchState($actor, $criteria->isFulltext());
$search->setQuery($query->getQuery());
$search->setQuery($query);
$this->filters->apply($search, $criteria->filters);
@ -45,6 +45,8 @@ abstract class AbstractSearcher implements SearcherInterface
$mutator($search, $criteria);
}
$query = $search->getQuery();
// Execute the search query and retrieve the results. We get one more
// results than the user asked for, so that we can say if there are more
// results. If there are, we will get rid of that extra result.

View File

@ -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
{

View File

@ -30,6 +30,7 @@ class SettingsServiceProvider extends AbstractServiceProvider
'search_driver_Flarum\Group\Group' => 'default',
'search_driver_Flarum\Post\Post' => 'default',
'search_driver_Flarum\Http\AccessToken' => 'default',
'pgsql_search_configuration' => 'english',
]);
});

View File

@ -13,7 +13,7 @@ use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Flarum\Search\ValidateFilterTrait;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Eloquent\Builder;
/**
* @implements FilterInterface<DatabaseSearchState>

View File

@ -15,7 +15,7 @@ use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Flarum\Search\ValidateFilterTrait;
use Flarum\User\User;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Eloquent\Builder;
/**
* @implements FilterInterface<DatabaseSearchState>
@ -50,7 +50,7 @@ class GroupFilter implements FilterInterface
$groupQuery = Group::whereVisibleTo($actor)
->join('group_user', 'groups.id', 'group_user.group_id')
->where(function (\Illuminate\Database\Eloquent\Builder $query) use ($ids, $names) {
->where(function (Builder $query) use ($ids, $names) {
$query->whereIn('groups.id', $ids)
->orWhereIn($query->raw('lower(name_singular)'), $names)
->orWhereIn($query->raw('lower(name_plural)'), $names);

View File

@ -367,7 +367,9 @@ class User extends AbstractModel
public function getNewNotificationCount(): int
{
return $this->unreadNotifications()
->where('created_at', '>', $this->read_notifications_at ?? 0)
->when($this->read_notifications_at, function (Builder|HasMany $query) {
$query->where('created_at', '>', $this->read_notifications_at);
})
->count();
}

View File

@ -85,7 +85,11 @@ class ListTest extends TestCase
])
);
$data = json_decode($response->getBody()->getContents(), true)['data'];
$body = $response->getBody()->getContents();
$this->assertEquals(200, $response->getStatusCode(), $body);
$data = json_decode($body, true)['data'];
// Order-independent comparison
$this->assertEqualsCanonicalizing(['2', '3'], Arr::pluck($data, 'id'), 'IDs do not match');
@ -123,7 +127,9 @@ class ListTest extends TestCase
])
);
$data = json_decode($response->getBody()->getContents(), true)['data'];
$data = json_decode($body = $response->getBody()->getContents(), true)['data'] ?? null;
$this->assertEquals(200, $response->getStatusCode(), $body);
// Order-independent comparison
$this->assertEquals(['3'], Arr::pluck($data, 'id'), 'IDs do not match');

View File

@ -85,7 +85,10 @@ class ListWithFulltextSearchTest extends TestCase
])
);
$data = json_decode($response->getBody()->getContents(), true);
$data = json_decode($body = $response->getBody()->getContents(), true);
$this->assertEquals(200, $response->getStatusCode(), $body);
$ids = array_map(function ($row) {
return $row['id'];
}, $data['data']);

View File

@ -9,6 +9,7 @@
namespace Flarum\Tests\integration\api\notifications;
use Carbon\Carbon;
use Flarum\Discussion\Discussion;
use Flarum\Notification\Notification;
use Flarum\Post\Post;
@ -38,7 +39,7 @@ class UpdateTest extends TestCase
['id' => 1, 'discussion_id' => 1, 'user_id' => 2, 'type' => 'comment', 'content' => 'Foo'],
],
Notification::class => [
['id' => 1, 'user_id' => 2, 'from_user_id' => 1, 'type' => 'discussionRenamed', 'subject_id' => 1, 'read_at' => null],
['id' => 1, 'user_id' => 2, 'from_user_id' => 1, 'type' => 'discussionRenamed', 'subject_id' => 1, 'read_at' => null, 'created_at' => Carbon::now()],
]
]);
}

View File

@ -30,19 +30,20 @@ class DeleteTest extends TestCase
$this->prepareDatabase([
User::class => [
$this->normalUser(),
['id' => 3, 'username' => 'acme', 'email' => 'acme@machine.local', 'is_email_confirmed' => 1],
['id' => 4, 'username' => 'acme2', 'email' => 'acme2@machine.local', 'is_email_confirmed' => 1],
],
Discussion::class => [
['id' => 3, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 2, 'first_post_id' => 1, 'comment_count' => 5, 'last_post_number' => 5, 'last_post_id' => 10],
['id' => 3, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 2, 'first_post_id' => 5, 'comment_count' => 5, 'last_post_number' => 5, 'last_post_id' => 10],
],
Post::class => [
['id' => 5, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 1],
['id' => 6, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 2],
['id' => 7, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 3],
['id' => 8, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 4],
['id' => 9, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 5],
['id' => 10, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 6],
['id' => 5, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(2)->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 1],
['id' => 6, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(3)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 2],
['id' => 7, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(4)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 3],
['id' => 8, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(5)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 4],
['id' => 9, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(6)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 5],
['id' => 10, 'discussion_id' => 3, 'created_at' => Carbon::createFromDate(1975, 5, 21)->addMinutes(7)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>foo bar</p></t>', 'number' => 6],
],
'discussion_user' => [
['discussion_id' => 3, 'user_id' => 2, 'last_read_post_number' => 6],

View File

@ -46,7 +46,7 @@ class GroupSearchTest extends TestCase
{
$response = $this->createRequest(['admin'], 1);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents());
}
/**

View File

@ -89,7 +89,7 @@ class ListTest extends TestCase
$this->assertEquals(200, $response->getStatusCode());
$data = json_decode($response->getBody()->getContents(), true)['data'];
$this->assertEquals(['1', '2'], Arr::pluck($data, 'id'));
$this->assertEqualsCanonicalizing(['1', '2'], Arr::pluck($data, 'id'));
}
/**

View File

@ -9,6 +9,7 @@
namespace Flarum\Tests\integration\extenders;
use Carbon\Carbon;
use Flarum\Discussion\Discussion;
use Flarum\Extend;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
@ -38,6 +39,7 @@ class ModelPrivateTest extends TestCase
$discussion = Discussion::create([
'title' => 'Some Discussion',
'user_id' => $user->id,
'created_at' => Carbon::now(),
]);
$this->assertNull($discussion->is_private);
@ -62,10 +64,12 @@ class ModelPrivateTest extends TestCase
$privateDiscussion = Discussion::create([
'title' => 'Private Discussion',
'user_id' => $user->id,
'created_at' => Carbon::now(),
]);
$publicDiscussion = Discussion::create([
'title' => 'Public Discussion',
'user_id' => $user->id,
'created_at' => Carbon::now(),
]);
$this->assertTrue($privateDiscussion->is_private);
@ -89,10 +93,12 @@ class ModelPrivateTest extends TestCase
$privateDiscussion = Discussion::create([
'title' => 'Private Discussion',
'user_id' => $user->id,
'created_at' => Carbon::now(),
]);
$publicDiscussion = Discussion::create([
'title' => 'Public Discussion',
'user_id' => $user->id,
'created_at' => Carbon::now(),
]);
$this->assertTrue($privateDiscussion->is_private);
@ -122,10 +128,12 @@ class ModelPrivateTest extends TestCase
$privateDiscussion = Discussion::create([
'title' => 'Private Discussion',
'user_id' => $user->id,
'created_at' => Carbon::now(),
]);
$publicDiscussion = Discussion::create([
'title' => 'Public Discussion',
'user_id' => $user->id,
'created_at' => Carbon::now(),
]);
$this->assertTrue($privateDiscussion->is_private);

View File

@ -13,9 +13,9 @@
</div>
<div class="FormGroup">
<div data-group="sqlite" style="display:none">
<div data-group="sqlite,pgsql" style="display:none">
<div class="Alert Alert--warning">
<strong>Warning:</strong> 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.
<strong>Warning:</strong> 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.
</div>
</div>
</div>
@ -25,6 +25,7 @@
<label>Database Driver</label>
<select class="FormControl" name="dbDriver">
<option value="mysql">MySQL</option>
<option value="pgsql">PostgreSQL</option>
<option value="sqlite">SQLite</option>
</select>
</div>
@ -34,20 +35,20 @@
<input class="FormControl" name="dbName" value="flarum">
</div>
<div data-group="mysql">
<div data-group="mysql,pgsql">
<div class="FormField">
<label>MySQL Host</label>
<input class="FormControl" name="mysqlHost" value="localhost">
<label>Host</label>
<input class="FormControl" name="dbHost" value="localhost">
</div>
<div class="FormField">
<label>MySQL Username</label>
<input class="FormControl" name="mysqlUsername">
<label>Username</label>
<input class="FormControl" name="dbUsername">
</div>
<div class="FormField">
<label>MySQL Password</label>
<input class="FormControl" type="password" name="mysqlPassword">
<label>Password</label>
<input class="FormControl" type="password" name="dbPassword">
</div>
</div>
@ -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';

View File

@ -40,7 +40,11 @@ class SetupScript
{
$this->driver = getenv('DB_DRIVER') ?: 'mysql';
$this->host = getenv('DB_HOST') ?: 'localhost';
$this->port = intval(getenv('DB_PORT') ?: 3306);
$this->port = intval(getenv('DB_PORT') ?: match ($this->driver) {
'mysql' => 3306,
'pgsql' => 5432,
default => 0,
});
$this->name = getenv('DB_DATABASE') ?: 'flarum_test';
$this->user = getenv('DB_USERNAME') ?: 'root';
$this->pass = getenv('DB_PASSWORD') ?? 'root';

View File

@ -201,6 +201,10 @@ abstract class TestCase extends \PHPUnit\Framework\TestCase
*/
$this->database()->getSchemaBuilder()->disableForeignKeyConstraints();
if ($this->database()->getDriverName() === 'pgsql') {
$this->database()->statement("SET session_replication_role = 'replica'");
}
$databaseContent = [];
foreach ($this->databaseContent as $tableOrModelClass => $_rows) {
@ -224,6 +228,8 @@ abstract class TestCase extends \PHPUnit\Framework\TestCase
}
}
$tables = [];
// Then, insert all rows required for this test case.
foreach ($databaseContent as $table => $data) {
foreach ($data['rows'] as $row) {
@ -238,9 +244,24 @@ abstract class TestCase extends \PHPUnit\Framework\TestCase
}
$this->database()->table($table)->updateOrInsert($unique, $row);
if (isset($row['id'])) {
$tables[$table] = 'id';
}
}
}
if ($this->database()->getDriverName() === 'pgsql') {
// PgSQL doesn't auto-increment the sequence when inserting the IDs manually.
foreach ($tables as $table => $id) {
$wrappedTable = $this->database()->getSchemaGrammar()->wrapTable($table);
$seq = $this->database()->getSchemaGrammar()->wrapTable($table.'_'.$id.'_seq');
$this->database()->statement("SELECT setval('$seq', (SELECT MAX($id) FROM $wrappedTable))");
}
$this->database()->statement("SET session_replication_role = 'origin'");
}
// And finally, turn on foreign key checks again.
$this->database()->getSchemaBuilder()->enableForeignKeyConstraints();
}