Skip to content

Conversation

@thorsten
Copy link
Owner

@thorsten thorsten commented Feb 2, 2026

Summary by CodeRabbit

  • Refactor

    • Installer split into focused components for schema, seeding, validation and orchestration; session accessor removed to improve encapsulation.
  • New Features

    • Dialect-aware schema builder and installer with dry-run SQL collection and safer create/drop handling.
    • Centralized default seed data and a readonly installation input object.
    • New installation runner and migration operations for user creation and form-input insertion.
  • Enhancements

    • Extended SQL type support (long text, blob) and FULLTEXT index capability across dialects.
  • Tests

    • Large test suite added for schema, installer, seeding, validation and runner behavior across dialects.

@coderabbitai
Copy link

coderabbitai bot commented Feb 2, 2026

📝 Walkthrough

Walkthrough

Refactors installation into modular components (schema builder, seeder, installer runner, input validator); Database factory now resolves dialect via DialectFactory and returns a SchemaInstaller; adds longText/blob dialect support, new migration operations, and extensive tests for installation/migration flows.

Changes

Cohort / File(s) Summary
Installation core
phpmyfaq/src/phpMyFAQ/Setup/Installation/DatabaseSchema.php, phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php, phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php
Adds dialect-agnostic DatabaseSchema (table builders), DefaultDataSeeder (default config/rights/form seeds), and SchemaInstaller (implements DriverInterface; create/drop tables, dry-run SQL collection and execution).
Installer / Runner / Input
phpmyfaq/src/phpMyFAQ/Setup/Installer.php, phpmyfaq/src/phpMyFAQ/Setup/InstallationInput.php, phpmyfaq/src/phpMyFAQ/Setup/InstallationInputValidator.php, phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php
Removes embedded installer data from Installer; introduces readonly InstallationInput, InstallationInputValidator for input parsing/validation, and InstallationRunner orchestrating installation steps; Installer::getFormInputs delegates to DefaultDataSeeder.
Database factory & Session wrapper
phpmyfaq/src/phpMyFAQ/Instance/Database.php, phpmyfaq/src/phpMyFAQ/Session/SessionWrapper.php
Database factory now uses DialectFactory::createForType() to obtain a dialect and returns a SchemaInstaller (with error handling); SessionWrapper no longer exposes getSession().
Migration dialects & TableBuilder
phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/DialectInterface.php, phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/*Dialect.php, phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/TableBuilder.php
Adds longText() and blob() to DialectInterface and dialect implementations; TableBuilder gains longText(), blob(), and fullTextIndex() support, plus index handling and minor signature/exception adjustments (Sqlite modifyColumn).
Migration operations & recorder
phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/UserCreateOperation.php, .../FormInputInsertOperation.php, .../OperationRecorder.php
Adds readonly Operation implementations for user creation and form-input insertion and two recorder helpers (createUser(), insertFormInput()) to enqueue these operations.
Update wiring
phpmyfaq/src/phpMyFAQ/Setup/Update.php
Moves admin-log-hash migration later in post-migration sequence and switches form-input seeding to use DefaultDataSeeder.
Tests
tests/phpMyFAQ/Setup/Installation/*, tests/phpMyFAQ/Setup/*, tests/phpMyFAQ/Setup/Migration/QueryBuilder/*, tests/phpMyFAQ/Instance/DatabaseTest.php, tests/phpMyFAQ/Session/SessionWrapperTest.php
Adds many unit tests: DatabaseSchemaTest, DefaultDataSeederTest, SchemaInstallerTest, InstallationRunnerTest, InstallationInputValidatorTest; updates factory/session tests and adds dialect-specific blob/longText tests.

Sequence Diagram(s)

sequenceDiagram
    participant Caller as Installer Caller
    participant Validator as InstallationInputValidator
    participant Runner as InstallationRunner
    participant SchemaInstaller as SchemaInstaller
    participant Seeder as DefaultDataSeeder
    participant DB as Database Driver
    participant Search as Search Initializer

    Caller->>Validator: validate(setup)
    Validator-->>Caller: InstallationInput

    Caller->>Runner: run(InstallationInput)
    activate Runner

    Runner->>DB: validate connectivity
    DB-->>Runner: ok

    Runner->>SchemaInstaller: createTables(prefix?)
    SchemaInstaller->>DB: executeSql(CREATE TABLE ...)
    DB-->>SchemaInstaller: result

    Runner->>Seeder: seed config & form inputs
    Seeder-->>Runner: done

    Runner->>Search: init indices (ES/OS) (if enabled)
    Search-->>Runner: indices ready
    deactivate Runner

    Runner-->>Caller: finished
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐇 I dug a schema, row by row,

Seeds and runners set to go,
Dialects learned to speak in kind,
Installers dance, no strings to bind,
Hooray — the burrow's ready to show!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.81% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'refactor(setup): improved installation and setup classes' accurately summarizes the main change—a comprehensive refactoring of installation and setup components. The title is clear, concise, and directly reflects the primary purpose of the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/rewrite-installation-process

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🤖 Fix all issues with AI agents
In `@phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php`:
- Around line 116-131: dropTables currently ignores any global table prefix when
called with an empty string; update the dropTables(string $prefix = '') method
in SchemaInstaller so that if $prefix === '' it falls back to the configured
global prefix from $this->configuration (call the appropriate getter on the
configuration object, e.g. getPrefix()/getTablePrefix()/getDbPrefix()), then use
that prefix when building the DROP TABLE SQL (the sprintf call that currently
uses $prefix and the loop over $this->schema->getTableNames()); keep the same
error handling that checks $this->configuration->getDb()->query($sql) and
returns false on failure.

In `@phpmyfaq/src/phpMyFAQ/Setup/InstallationInput.php`:
- Around line 22-45: Change the sensitive credential properties in the readonly
InstallationInput class from public to private (at minimum password and
loginName; consider email/permLevel if they carry sensitive data) and add
explicit getter methods (e.g., getPassword(), getLoginName()) to expose them
only when needed; update the constructor signature to accept these values and
assign them to the private properties, and adjust any code that referenced the
public properties to use the new accessors (look for references to
InstallationInput::password, ::loginName, and other credential fields).

In `@phpmyfaq/src/phpMyFAQ/Setup/InstallationInputValidator.php`:
- Around line 136-139: The null check on $dbSetup['dbUser'] after calling
Filter::filterInput with a default of '' is ineffective (is_null will never be
true); update the validation in InstallationInputValidator.php to detect missing
username correctly by either changing the filterInput default to null or
replacing is_null($dbSetup['dbUser']) with a check for an empty value (e.g.,
$dbSetup['dbUser'] === '' or empty($dbSetup['dbUser'])) while preserving the
System::isSqlite($dbSetup['dbType']) exemption and throwing the same
Exception('Installation Error: Please add a database username.') when
appropriate.
- Around line 121-124: The null check is ineffective because Filter::filterInput
returns '' by default; in InstallationInputValidator.php replace the is_null
check on $dbSetup['dbServer'] (populated via Filter::filterInput) with an
emptiness check (e.g., trim($dbSetup['dbServer']) === '' or empty(trim(...)))
and keep the System::isSqlite($dbSetup['dbType']) exclusion so the throw in the
validation block still fires for non-SQLite when the server string is empty.

In `@phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php`:
- Around line 275-293: The stepCreateAdminUser method currently ignores failures
from User::setStatus, User::setUserData, and User::setSuperAdmin; update
stepCreateAdminUser to check each call's return value and treat any false result
as a fatal installation error: call Installer::cleanFailedInstallationFiles()
and throw an Exception with a descriptive message including the User::error()
output. Locate the calls to setStatus('protected'), setUserData($adminData), and
setSuperAdmin(true) and perform immediate checks after each (or aggregate
checks) so the installation aborts and logs the specific user error if any
mutation fails, similar to how createUser failures are handled.
- Around line 355-381: In stepInitializeSearchEngine(InstallationInput $input)
the code checks/is_file and loads elasticsearch.php and opensearch.php from
"{$input->rootDir}/config/..." but earlier installation writes those files to
"{$input->rootDir}/content/core/config/..."; update the path used in both
elasticsearch and opensearch flows (the is_file checks and the constructors
ElasticsearchConfiguration(...) and OpenSearchConfiguration(...)) to point to
"{$input->rootDir}/content/core/config/..." so that setElasticsearchConfig(...),
setOpenSearchConfig(...), setElasticsearch(...), setOpenSearch(...) and
subsequent createIndex() calls operate on the actual written config files.
- Around line 89-157: In stepValidateConnectivity update the Elasticsearch and
OpenSearch checks to actually probe the servers: after creating $esClient via
ClientBuilder::create()->setHosts(...)->build() call $esClient->ping() (or
$esClient->info()) inside a try-catch and throw an Exception if the call returns
false or an exception is thrown; similarly, after creating $osClient with
SymfonyClientFactory()->create() perform a HEAD request to '/' (e.g.
$osClient->request('HEAD','/')) inside a try-catch and throw an Exception on
non-2xx responses or exceptions; wrap both probes to catch network errors and
return clear installation errors instead of relying on the client object being
non-null.

In
`@phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/FormInputInsertOperation.php`:
- Around line 52-57: The execute() method currently always returns true even
though Forms::insertInputIntoDatabase($this->formInput) returns a boolean;
change execute() to capture that boolean result from
$forms->insertInputIntoDatabase(...) and return that result (or return false on
failure), so the insert result is propagated; locate the Forms instantiation in
execute() and replace the unconditional return true with returning the actual
insert outcome from Forms::insertInputIntoDatabase.

In `@phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/OperationRecorder.php`:
- Around line 125-158: The method declaration for createUser has its opening
brace on the same line; move the brace to the next line to comply with PER-3
(adjust the createUser(...) declaration so its "{" is on the following line),
and ensure the rest of the method (the new UserCreateOperation push and return
$this) keeps its current indentation and formatting; verify neighboring methods
(e.g., insertFormInput) still follow PER-3 for consistency.

In `@phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/UserCreateOperation.php`:
- Around line 51-74: The execute() method currently ignores boolean return
values from User::setStatus, User::setUserData, and User::setSuperAdmin so it
can return true despite partial failures; update execute() to check each call's
return and immediately return false if any of setStatus($this->status),
setUserData($userData) or (when $this->isSuperAdmin) setSuperAdmin(true) returns
false (while still preserving the initial createUser(...) check and the
try/catch behavior).

In `@phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/TableBuilder.php`:
- Around line 329-331: The check in TableBuilder::buildIndexStatements()
accesses $this->tableName before it's guaranteed to be initialized; change the
condition to guard for an uninitialized or empty property (e.g. if
(!isset($this->tableName) || $this->tableName === '') ) and throw the
LogicException if true—ensure this mirrors how table() sets the property so
buildIndexStatements() no longer reads an uninitialized $this->tableName.
- Around line 254-256: The check in TableBuilder::build() accesses the typed
property $tableName before it's guaranteed to be initialized, causing a fatal
Error if table() was never called; update the guard to avoid direct access to an
uninitialized property (e.g. initialize $tableName with a default empty string
when declared or change the check to use isset($this->tableName) or null
coalescing like ($this->tableName ?? '') === ''), and keep the thrown
LogicException message the same; touch the TableBuilder::$tableName declaration
or modify the build() guard to reference isset/?? so build() and table() logic
remain consistent.

In `@tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php`:
- Around line 103-123: The test testPostgresNoIndexStatementsDuplicate currently
only asserts there is at least one CREATE INDEX but doesn't verify duplicates;
update the test in SchemaInstallerTest::testPostgresNoIndexStatementsDuplicate
to collect the actual CREATE INDEX statements from $sql (e.g. into an $indexes
array), then assert that count($indexes) equals count(array_unique($indexes))
(or use $this->assertCount and $this->assertEquals) to ensure no duplicate
CREATE INDEX statements are emitted by SchemaInstaller::createTables and
returned from getCollectedSql.
🧹 Nitpick comments (7)
tests/phpMyFAQ/Session/SessionWrapperTest.php (1)

26-33: Consider clarifying or removing the trailing comment.

The comment on lines 31-32 states "Instead, verify the wrapper works by testing delegation" but this test method doesn't actually perform that verification—delegation is tested by the other test methods in this class. The dangling comment could be confusing to future readers.

Consider either removing the comment entirely or rephrasing it to clarify that delegation tests exist separately:

✏️ Suggested improvement
     public function testConstructorWithSessionParameter(): void
     {
         $sessionWrapper = new SessionWrapper($this->sessionMock);
         $this->assertInstanceOf(SessionWrapper::class, $sessionWrapper);
-
-        // The session is private, so we can't access it directly
-        // Instead, verify the wrapper works by testing delegation
+        // Delegation behavior is verified in separate test methods (e.g., testGetMethodDelegatesToSession)
     }
phpmyfaq/src/phpMyFAQ/Setup/Update.php (1)

360-396: Redundant version check in migrateAdminLogHashes().

The version check at Line 362 is already guarded by the same check at Line 263 in runPostMigrationTasks(). The inner check is redundant and can be removed to simplify the code.

♻️ Proposed simplification
 private function migrateAdminLogHashes(): void
 {
-    if (version_compare($this->version, '4.2.0-alpha', '<')) {
-        $repository = new AdminLogRepository($this->configuration);
+    $repository = new AdminLogRepository($this->configuration);
 
-        try {
-            $entries = $repository->getAll();
-            $previousHash = null;
+    try {
+        $entries = $repository->getAll();
+        $previousHash = null;
 
-            foreach ($entries as $entity) {
-                if ($entity->getHash() !== null) {
-                    continue;
-                }
+        foreach ($entries as $entity) {
+            if ($entity->getHash() !== null) {
+                continue;
+            }
 
-                $entity->setPreviousHash($previousHash);
-                $hash = $entity->calculateHash();
+            $entity->setPreviousHash($previousHash);
+            $hash = $entity->calculateHash();
 
-                // Execute UPDATE directly instead of adding to the queries array
-                $updateQuery = sprintf(
-                    "UPDATE %sfaqadminlog SET hash = '%s', previous_hash = %s WHERE id = %d",
-                    Database::getTablePrefix(),
-                    $this->configuration->getDb()->escape($hash),
-                    $previousHash !== null
-                        ? "'" . $this->configuration->getDb()->escape($previousHash) . "'"
-                        : 'NULL',
-                    $entity->getId(),
-                );
+            // Execute UPDATE directly instead of adding to the queries array
+            $updateQuery = sprintf(
+                "UPDATE %sfaqadminlog SET hash = '%s', previous_hash = %s WHERE id = %d",
+                Database::getTablePrefix(),
+                $this->configuration->getDb()->escape($hash),
+                $previousHash !== null
+                    ? "'" . $this->configuration->getDb()->escape($previousHash) . "'"
+                    : 'NULL',
+                $entity->getId(),
+            );
 
-                $this->configuration->getDb()->query($updateQuery);
+            $this->configuration->getDb()->query($updateQuery);
 
-                $previousHash = $hash;
-            }
-        } catch (\Exception $e) {
-            $this->configuration->getLogger()->error('Admin log hash migration failed: ' . $e->getMessage());
+            $previousHash = $hash;
         }
+    } catch (\Exception $e) {
+        $this->configuration->getLogger()->error('Admin log hash migration failed: ' . $e->getMessage());
     }
 }
phpmyfaq/src/phpMyFAQ/Setup/InstallationInputValidator.php (1)

311-316: Password length validation uses inconsistent boundary.

The check strlen(...) <= 7 means passwords of exactly 8 characters pass, but the error message says "minimum of 8 characters". Consider using < 8 for clarity and consistency with the message.

♻️ Proposed fix for clarity
-        if (strlen((string) $password) <= 7 || strlen((string) $passwordRetyped) <= 7) {
+        if (strlen((string) $password) < 8 || strlen((string) $passwordRetyped) < 8) {
             throw new Exception(
                 'Installation Error: Your password and retyped password are too short. Please set your password '
                 . 'and your retyped password with a minimum of 8 characters.',
             );
         }
tests/phpMyFAQ/Setup/InstallationInputValidatorTest.php (1)

25-55: Test name is misleading - doesn't actually verify readonly behavior.

The test testInstallationInputIsReadonly only asserts that properties can be read with expected values. It doesn't verify the readonly constraint (that properties cannot be modified after construction).

Consider renaming or adding actual readonly verification.

♻️ Suggested improvement

Either rename the test:

-    public function testInstallationInputIsReadonly(): void
+    public function testInstallationInputCanBeConstructedWithExpectedValues(): void

Or add actual readonly verification (PHP 8.1+ readonly properties throw Error on modification):

public function testInstallationInputIsReadonly(): void
{
    $input = new InstallationInput(/* ... */);
    
    $this->expectException(\Error::class);
    $input->loginName = 'changed'; // Should throw
}
tests/phpMyFAQ/Setup/InstallationRunnerTest.php (1)

67-101: Source code parsing test is fragile.

This test parses the raw source code to verify step order, which is brittle:

  • Refactoring (e.g., extracting steps to a loop over an array) would break the test even if behavior is correct.
  • Comments mentioning step names would cause false positives.

Consider using a spy/mock pattern or extracting the step list to a constant that both the implementation and test reference.

♻️ Alternative approach

If InstallationRunner exposed its step order (e.g., via a constant or protected method), tests could verify against that contract without parsing source:

// In InstallationRunner
protected const STEP_ORDER = [
    'stepValidateConnectivity',
    'stepCreateConfigFiles',
    // ...
];

// In test
public function testRunCallsStepsInCorrectOrder(): void
{
    $this->assertEquals(
        self::EXPECTED_STEPS,
        InstallationRunner::STEP_ORDER
    );
}
phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/Dialect/SqliteDialect.php (1)

119-124: Include context in the SQLite modifyColumn() exception.
It will make migration failures easier to diagnose.

♻️ Suggested tweak
-        throw new RuntimeException('SQLite does not support modifying columns. Use table rebuild pattern.');
+        throw new RuntimeException(
+            sprintf(
+                'SQLite does not support modifying columns. Use table rebuild pattern. Table: %s, Column: %s, Type: %s',
+                $tableName,
+                $columnName,
+                $newType,
+            ),
+        );
tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php (1)

30-54: Avoid hard‑coding the table count in dry‑run assertions.
Using the schema as the source of truth keeps the test resilient to future table additions.

♻️ Suggested tweak
-        // Should have at least one CREATE TABLE per table definition
-        $createTableCount = 0;
+        $expectedTableCount = count($installer->getSchema()->getTableNames());
+        $createTableCount = 0;
         foreach ($sql as $statement) {
             if (str_contains($statement, 'CREATE TABLE')) {
                 $createTableCount++;
             }
         }
-        $this->assertEquals(43, $createTableCount, 'Should generate CREATE TABLE for all 43 tables');
+        $this->assertSame(
+            $expectedTableCount,
+            $createTableCount,
+            sprintf('Should generate CREATE TABLE for all %d tables', $expectedTableCount),
+        );

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@phpmyfaq/.htaccess`:
- Line 105: The .htaccess currently contains absolute local filesystem paths;
replace those with URL-relative paths: update the RewriteBase directive (symbol:
RewriteBase) to a URL path such as "/" or "/phpmyfaq/" depending on your
deployment, and change the ErrorDocument 404 directive (symbol: ErrorDocument)
to point to a URL path (e.g., "/404.html" or "/phpmyfaq/404.html") instead of
any /Users/... filesystem path.

In `@phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php`:
- Around line 119-135: The dropTables method currently executes DROP queries
directly and ignores dry-run and collectedSql; change
SchemaInstaller::dropTables to build the DROP TABLE statements as it does now
but call $this->executeSql($sql) (the same helper used by createTables())
instead of $this->configuration->getDb()->query(...), so each DROP is appended
to collectedSql and the dryRun flag is respected; ensure you still return false
on failure (propagate executeSql's boolean) and reference the existing
properties/methods: dropTables, executeSql, createTables, collectedSql, and
dryRun.

In `@phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php`:
- Around line 92-99: The connect() calls created via Database::factory(...)
inside InstallationRunner must handle both exceptions and the declared ?bool
return: wrap each DatabaseDriver::connect(...) invocation in a try-catch that
catches Throwable/Exception, rethrow or convert it to an Installation-specific
failure, and explicitly check the returned value (if === false) to treat it as a
connection failure (again throwing/returning a descriptive error); also preserve
and use the DatabaseDriver instanceof check only for type validation but not as
a proxy for successful connection—ensure the catch path logs or surfaces the
original exception message so connection failures are not silently ignored.
- Around line 153-156: Change the OpenSearch client creation in
InstallationRunner to enable TLS verification by default: set 'verify_peer' =>
true instead of false in the SymfonyClientFactory()->create(...) calls (both
occurrences where $osClient is built). Add support for optional CA bundle
parameters (e.g., 'cafile' and 'capath') passed into the client config from
installation settings/env so self-signed certs can be validated in
non-production environments, and ensure any code that constructs the options for
SymfonyClientFactory (the array passed into create) merges explicit
'verify_peer' and 'cafile'/'capath' values rather than hardcoding verification
off.
🧹 Nitpick comments (6)
phpmyfaq/src/phpMyFAQ/Setup/Migration/Operations/FormInputInsertOperation.php (1)

42-50: Consider defensive array key access.

The method accesses form_id, input_id, and input_label keys directly. If the array is malformed, this will throw an undefined key warning. Since this is an internal operation class with controlled inputs, this is a minor concern.

🛡️ Optional defensive approach
     public function getDescription(): string
     {
         return sprintf(
             'Insert form input: form=%d, input=%d, label=%s',
-            $this->formInput['form_id'],
-            $this->formInput['input_id'],
-            $this->formInput['input_label'],
+            $this->formInput['form_id'] ?? 0,
+            $this->formInput['input_id'] ?? 0,
+            $this->formInput['input_label'] ?? 'unknown',
         );
     }
phpmyfaq/src/phpMyFAQ/Setup/Migration/QueryBuilder/TableBuilder.php (1)

297-300: Minor style inconsistency in column list formatting.

Line 298 uses implode(',', $ftColumns) without spaces, while line 303 uses implode(', ', $indexDef['columns']) with a space after the comma. Consider using consistent formatting for SQL output.

♻️ Suggested fix for consistency
 foreach ($this->fullTextIndexes as $ftColumns) {
-    $columnList = implode(',', $ftColumns);
+    $columnList = implode(', ', $ftColumns);
     $parts[] = "FULLTEXT ($columnList)";
 }
phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php (1)

137-152: Minor: return statement can be simplified.

The conditional return is functionally correct but verbose.

✨ Optional simplification
     private function executeSql(string $sql): bool
     {
         $this->collectedSql[] = $sql;

         if ($this->dryRun) {
             return true;
         }

-        $result = $this->configuration->getDb()->query($sql);
-
-        if (!$result) {
-            return false;
-        }
-
-        return true;
+        return (bool) $this->configuration->getDb()->query($sql);
     }
tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php (2)

30-54: Hardcoded table count (43) may become brittle.

The assertion on line 53 expects exactly 43 tables. If tables are added or removed from DatabaseSchema, this test will fail without clear indication of what changed.

✨ Consider deriving expected count from schema
+    #[DataProvider('dialectProvider')]
+    public function testDryRunCollectsAllSql(DialectInterface $dialect): void
+    {
         $db = $this->createStub(DatabaseDriver::class);
         $configuration = $this->createStub(Configuration::class);
         $configuration->method('getDb')->willReturn($db);

         $installer = new SchemaInstaller($configuration, $dialect);
         $installer->setDryRun(true);

         $result = $installer->createTables('');
         $this->assertTrue($result);

         $sql = $installer->getCollectedSql();
         $this->assertNotEmpty($sql);

+        $expectedTableCount = count($installer->getSchema()->getTableNames());
+
         // Should have at least one CREATE TABLE per table definition
         $createTableCount = 0;
         foreach ($sql as $statement) {
             if (str_contains($statement, 'CREATE TABLE')) {
                 $createTableCount++;
             }
         }
-        $this->assertEquals(43, $createTableCount, 'Should generate CREATE TABLE for all 43 tables');
+        $this->assertEquals(
+            $expectedTableCount,
+            $createTableCount,
+            "Should generate CREATE TABLE for all {$expectedTableCount} tables"
+        );
     }

15-130: Consider adding test coverage for dropTables().

The test suite covers createTables() comprehensively but lacks tests for dropTables(). Key scenarios to consider:

  1. dropTables() with explicit prefix
  2. dropTables() falling back to global prefix
  3. Error handling when DROP fails
  4. Dry-run mode behavior (currently inconsistent with implementation)
phpmyfaq/src/phpMyFAQ/Setup/InstallationInputValidator.php (1)

62-67: Consider honoring $setup overrides for non‑DB fields.

validate() is documented for programmatic installs, but language/realname/email/permLevel (and dbPrefix) always come from POST. Allowing $setup values keeps CLI/automation installs consistent with UI installs.

♻️ Suggested refactor
-        $language = Filter::filterInput(INPUT_POST, 'language', FILTER_SANITIZE_SPECIAL_CHARS, 'en');
-        $realname = Filter::filterInput(INPUT_POST, 'realname', FILTER_SANITIZE_SPECIAL_CHARS, '');
-        $email = Filter::filterInput(INPUT_POST, 'email', FILTER_SANITIZE_EMAIL, '');
-        $permLevel = Filter::filterInput(INPUT_POST, 'permLevel', FILTER_SANITIZE_SPECIAL_CHARS, 'basic');
+        $language = $setup['language'] ?? Filter::filterInput(INPUT_POST, 'language', FILTER_SANITIZE_SPECIAL_CHARS, 'en');
+        $realname = $setup['realname'] ?? Filter::filterInput(INPUT_POST, 'realname', FILTER_SANITIZE_SPECIAL_CHARS, '');
+        $email = $setup['email'] ?? Filter::filterInput(INPUT_POST, 'email', FILTER_SANITIZE_EMAIL, '');
+        $permLevel = $setup['permLevel'] ?? Filter::filterInput(INPUT_POST, 'permLevel', FILTER_SANITIZE_SPECIAL_CHARS, 'basic');

-        $dbSetup['dbPrefix'] = Filter::filterInput(INPUT_POST, 'sqltblpre', FILTER_SANITIZE_SPECIAL_CHARS, '');
+        $dbSetup['dbPrefix'] = $setup['dbPrefix'] ?? Filter::filterInput(INPUT_POST, 'sqltblpre', FILTER_SANITIZE_SPECIAL_CHARS, '');

Also applies to: 95-98

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php`:
- Around line 278-281: The salt is generated deterministically with
md5($this->configuration->getDefaultUrl()) in InstallationRunner; replace this
with a cryptographically secure random value (e.g., use random_bytes and encode
with bin2hex or base64_encode) when calling
$this->configuration->add('security.salt', ...); ensure you generate sufficient
entropy (e.g., 32 bytes) and handle any potential exceptions from random_bytes
so the salt is unpredictable and high-entropy.
- Around line 137-141: The ping response from $esClient->ping() in
InstallationRunner must be checked — call ->asBool() on the response and treat a
false result as a failure (e.g., throw or log and enter the catch flow) instead
of only relying on exceptions; update the InstallationRunner code around
$esClient->ping() to mirror the successful check used in the existing phpMyFAQ
Instance Search Elasticsearch implementation so that non-OK HTTP responses are
handled as errors.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php`:
- Around line 39-42: The public typed property $collectedSql on SchemaInstaller
is declared but not initialized, which can cause a fatal uninitialized property
access before createTables() runs; initialize $collectedSql (either inline to an
empty array or in the SchemaInstaller constructor) so it is always an array when
accessed, e.g. set $collectedSql = [] in the class property or assign [] in
__construct() to ensure safe access from methods like createTables().

In `@phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php`:
- Around line 265-274: In stepCreateDatabaseTables, treat a false return from
InstanceDatabase::factory(...)->createTables(...) as a fatal error: after
calling createTables on the $databaseInstaller, check its boolean result; if it
is false, call Installer::cleanFailedInstallationFiles() and throw a new
Exception (including a clear message that table creation failed, and optionally
the dbType/dbPrefix context) so installation stops; keep the existing catch
block for thrown Exceptions from createTables as well.
- Around line 113-121: InstallationRunner is directly accessing the private
DefaultDataSeeder::$mainConfig (via $seeder->mainConfig) which will cause a
fatal error; add a public accessor on DefaultDataSeeder such as getConfig() or
getConfigByPrefix(string $prefix) that returns the config array (or filtered
entries), then update InstallationRunner to call the new method (e.g.,
$seeder->getConfigByPrefix('ldap.') or filter $seeder->getConfig()) and iterate
over that result instead of reading $mainConfig directly.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php`:
- Around line 399-413: The call in stepCreateInstance uses
Request::createFromGlobals()->getScriptName() passed into Link::getSystemUri(),
which triggers duplicate removal in getSystemRelativeUri() and can yield an
empty/incorrect URI and unreliable server globals in non-HTTP contexts; change
the setUrl invocation to call Link::getSystemUri() with no parameter (i.e., pass
null/omit the path) so Link::getSystemUri()/getSystemRelativeUri() compute the
correct relative URI internally and avoid using Request::createFromGlobals()
here (adjust the setUrl line in stepCreateInstance of InstallationRunner.php to
remove the Request::createFromGlobals() argument).
🧹 Nitpick comments (4)
phpmyfaq/src/phpMyFAQ/Setup/InstallationRunner.php (3)

361-370: Unused parameter $input.

The $input parameter is declared but never used in this method. Consider removing it if not needed for interface consistency.

🧹 Proposed fix
-    private function stepGrantPermissions(InstallationInput $input): void
+    private function stepGrantPermissions(): void
     {
         $user = new User($this->configuration);
         $user->getUserById(1, true);

And update the call site in run():

-        $this->stepGrantPermissions($input);
+        $this->stepGrantPermissions();

418-446: Add exception handling for createIndex() calls.

Lines 429 and 444 call createIndex() on Elasticsearch/OpenSearch instances without try-catch protection. If index creation fails, it may throw an exception that bubbles up without proper cleanup or a descriptive installation error message.

🛡️ Proposed fix
         if ($input->esEnabled && is_file($input->rootDir . '/content/core/config/elasticsearch.php')) {
             $elasticsearchConfiguration = new ElasticsearchConfiguration($input->rootDir
             . '/content/core/config/elasticsearch.php');
             $this->configuration->setElasticsearchConfig($elasticsearchConfiguration);

             $esClient = ClientBuilder::create()->setHosts($elasticsearchConfiguration->getHosts())->build();
             $this->configuration->setElasticsearch($esClient);

             $elasticsearch = new Elasticsearch($this->configuration);
-            $elasticsearch->createIndex();
+            try {
+                $elasticsearch->createIndex();
+            } catch (\Throwable $e) {
+                throw new Exception(sprintf('Elasticsearch Installation Error: Could not create index: %s', $e->getMessage()));
+            }
         }

         if ($input->osEnabled && is_file($input->rootDir . '/content/core/config/opensearch.php')) {
             $openSearchConfiguration = new OpenSearchConfiguration($input->rootDir
             . '/content/core/config/opensearch.php');
             $this->configuration->setOpenSearchConfig($openSearchConfiguration);

             $osClient = new SymfonyClientFactory()->create($this->buildOpenSearchClientOptions(
                 $openSearchConfiguration->getHosts()[0],
                 $input->osSetup,
             ));
             $this->configuration->setOpenSearch($osClient);

             $openSearch = new OpenSearch($this->configuration);
-            $openSearch->createIndex();
+            try {
+                $openSearch->createIndex();
+            } catch (\Throwable $e) {
+                throw new Exception(sprintf('OpenSearch Installation Error: Could not create index: %s', $e->getMessage()));
+            }
         }

288-294: Check the return value from executeInsertQueries or throw an exception on failure.

Line 291 calls $stopWords->executeInsertQueries() which returns a bool, but the return value is ignored. If stopword insertion fails, the method only echoes error details to output while the installation continues without error. This is inconsistent with stepCreateDatabaseTables which properly checks the return value and throws an exception on failure.

phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php (1)

108-108: Add IF EXISTS to DROP TABLE statements for improved resilience.

All supported databases (MySQL, MariaDB, PostgreSQL, SQLite, SQL Server) support DROP TABLE IF EXISTS syntax. This change would prevent failures when tables don't exist during partial installation or repeated cleanup operations. The codebase already follows this pattern in AbstractMigration::dropTableIfExists() and QueryBuilder::dropTableIfExists().

♻️ Suggested improvement
-            $sql = sprintf('DROP TABLE %s%s', $prefix, $tableName);
+            $sql = sprintf('DROP TABLE IF EXISTS %s%s', $prefix, $tableName);

@thorsten thorsten merged commit cc063fb into main Feb 2, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant