diff --git a/Core/API/SettingsAPI.class.php b/Core/API/SettingsAPI.class.php index 26e4928..34b6b57 100644 --- a/Core/API/SettingsAPI.class.php +++ b/Core/API/SettingsAPI.class.php @@ -63,7 +63,6 @@ namespace Core\API\Settings { public function _execute(): bool { $key = $this->getParam("key"); $sql = $this->context->getSQL(); - $siteSettings = $this->context->getSettings(); $settings = Settings::getAll($sql, $key, $this->isExternalCall()); if ($settings !== null) { diff --git a/Core/Configuration/CreateDatabase.class.php b/Core/Configuration/CreateDatabase.class.php index fdd0f33..f173c7a 100644 --- a/Core/Configuration/CreateDatabase.class.php +++ b/Core/Configuration/CreateDatabase.class.php @@ -3,12 +3,24 @@ namespace Core\Configuration; use Core\API\Request; +use Core\Driver\Logger\Logger; +use Core\Driver\SQL\Query\CreateTable; use Core\Driver\SQL\SQL; use Core\Objects\DatabaseEntity\Controller\DatabaseEntity; use PHPUnit\Util\Exception; class CreateDatabase { + private static ?Logger $logger = null; + + public static function getLogger(SQL $sql): Logger { + if (self::$logger === null) { + self::$logger = new Logger("CreateDatabase", $sql); + } + + return self::$logger; + } + public static function createQueries(SQL $sql): array { $queries = array(); @@ -51,35 +63,54 @@ class CreateDatabase { } } - private static function loadEntities(SQL $sql, array &$queries): void { - $persistables = []; - $baseDirs = ["Core", "Site"]; - foreach ($baseDirs as $baseDir) { - $entityDirectory = "./$baseDir/Objects/DatabaseEntity/"; - if (file_exists($entityDirectory) && is_dir($entityDirectory)) { - $scan_arr = scandir($entityDirectory); - $files_arr = array_diff($scan_arr, array('.', '..')); - foreach ($files_arr as $file) { - $suffix = ".class.php"; - if (endsWith($file, $suffix)) { - $className = substr($file, 0, strlen($file) - strlen($suffix)); - $className = "\\$baseDir\\Objects\\DatabaseEntity\\$className"; - $reflectionClass = new \ReflectionClass($className); - if ($reflectionClass->isSubclassOf(DatabaseEntity::class)) { - $method = "$className::getHandler"; - $handler = call_user_func($method, $sql, null, true); - $persistables[$handler->getTableName()] = $handler; - foreach ($handler->getNMRelations() as $nmTableName => $nmRelation) { - $persistables[$nmTableName] = $nmRelation; - } - } + private static function getCreatedTables(SQL $sql, array $queries): ?array { + $createdTables = $sql->listTables(); + + if ($createdTables !== null) { + foreach ($queries as $query) { + if ($query instanceof CreateTable) { + $tableName = $query->getTableName(); + if (!in_array($tableName, $createdTables)) { + $createdTables[] = $tableName; } } } + } else { + self::getLogger($sql)->warning("Error querying existing tables: " . $sql->getLastError()); } + return $createdTables; + } + + public static function createEntityQueries(SQL $sql, array $entityClasses, array &$queries, bool $skipExisting = false): void { + + if (empty($entityClasses)) { + return; + } + + // first, check what tables are already created + $createdTables = self::getCreatedTables($sql, $queries); + if ($createdTables === null) { + throw new \Exception("Error querying existing tables"); + } + + // then collect all persistable entities (tables, relations, etc.) + $persistables = []; + foreach ($entityClasses as $className) { + $reflectionClass = new \ReflectionClass($className); + if ($reflectionClass->isSubclassOf(DatabaseEntity::class)) { + $handler = ("$className::getHandler")($sql, null, true); + $persistables[$handler->getTableName()] = $handler; + foreach ($handler->getNMRelations() as $nmTableName => $nmRelation) { + $persistables[$nmTableName] = $nmRelation; + } + } else { + throw new \Exception("Class '$className' is not a subclass of DatabaseEntity"); + } + } + + // now order the persistable entities so all dependencies are met. $tableCount = count($persistables); - $createdTables = []; while (!empty($persistables)) { $prevCount = $tableCount; $unmetDependenciesTotal = []; @@ -88,7 +119,7 @@ class CreateDatabase { $dependsOn = $persistable->dependsOn(); $unmetDependencies = array_diff($dependsOn, $createdTables); if (empty($unmetDependencies)) { - $queries = array_merge($queries, $persistable->getCreateQueries($sql)); + $queries = array_merge($queries, $persistable->getCreateQueries($sql, $skipExisting)); $createdTables[] = $tableName; unset($persistables[$tableName]); } else { @@ -104,6 +135,32 @@ class CreateDatabase { } } + private static function loadEntities(SQL $sql, array &$queries): void { + + $classes = []; + $baseDirs = ["Core", "Site"]; + foreach ($baseDirs as $baseDir) { + $entityDirectory = "./$baseDir/Objects/DatabaseEntity/"; + if (file_exists($entityDirectory) && is_dir($entityDirectory)) { + $scan_arr = scandir($entityDirectory); + $files_arr = array_diff($scan_arr, [".", ".."]); + foreach ($files_arr as $file) { + $suffix = ".class.php"; + if (endsWith($file, $suffix)) { + $className = substr($file, 0, strlen($file) - strlen($suffix)); + $className = "\\$baseDir\\Objects\\DatabaseEntity\\$className"; + $reflectionClass = new \ReflectionClass($className); + if ($reflectionClass->isSubclassOf(DatabaseEntity::class)) { + $classes[] = $className; + } + } + } + } + } + + self::createEntityQueries($sql, $classes, $queries); + } + public static function loadDefaultACL(SQL $sql, array &$queries): void { $query = $sql->insert("ApiPermission", ["method", "groups", "description", "is_core"]); diff --git a/Core/Configuration/Settings.class.php b/Core/Configuration/Settings.class.php index 4b8de70..71b7c6a 100644 --- a/Core/Configuration/Settings.class.php +++ b/Core/Configuration/Settings.class.php @@ -62,7 +62,14 @@ class Settings { } public static function getAll(?SQL $sql, ?string $pattern = null, bool $external = false): ?array { - $query = $sql->select("name", "value")->from("Settings"); + + // We do not have a Settings table yet, we might still be in installation phase + if (!$sql->tableExists("Settings")) { + return null; + } + + $query = $sql->select("name", "value") + ->from("Settings"); if ($pattern) { $query->where(new CondRegex(new Column("name"), $pattern)); diff --git a/Core/Documents/Install.class.php b/Core/Documents/Install.class.php index 5c7e8f5..bcc5a51 100644 --- a/Core/Documents/Install.class.php +++ b/Core/Documents/Install.class.php @@ -19,7 +19,6 @@ namespace Documents\Install { use Core\Configuration\Configuration; use Core\Configuration\CreateDatabase; - use Core\Driver\SQL\Expression\Count; use Core\Driver\SQL\SQL; use Core\Elements\Body; use Core\Elements\Head; @@ -187,7 +186,7 @@ namespace Documents\Install { } $sql = $context->getSQL(); - if (!$sql || !$sql->isConnected()) { + if (!$sql || !$sql->isConnected() || !$sql->tableExists(User::getHandler($sql)->getTableName())) { return self::DATABASE_CONFIGURATION; } @@ -439,9 +438,12 @@ namespace Documents\Install { $context = $this->getDocument()->getContext(); if ($this->getParameter("prev") === "true") { // TODO: drop the previous database here? + /* $success = $context->getConfig()->delete("\\Site\\Configuration\\Database"); $msg = $success ? "" : error_get_last(); return ["success" => $success, "msg" => $msg]; + */ + return ["success" => false, "msg" => "Cannot revert this installation step."]; } $username = $this->getParameter("username"); @@ -755,7 +757,7 @@ namespace Documents\Install { ["title" => "Password", "name" => "password", "type" => "password", "required" => true], ["title" => "Confirm Password", "name" => "confirmPassword", "type" => "password", "required" => true], ], - "previousButton" => true + "previousButton" => false, ], self::ADD_MAIL_SERVICE => [ "title" => "Optional: Add Mail Service", diff --git a/Core/Driver/SQL/Expression/DateAdd.class.php b/Core/Driver/SQL/Expression/DateAdd.class.php index 430c2ec..7a7a678 100644 --- a/Core/Driver/SQL/Expression/DateAdd.class.php +++ b/Core/Driver/SQL/Expression/DateAdd.class.php @@ -6,7 +6,7 @@ use Core\Driver\SQL\Column\Column; use Core\Driver\SQL\MySQL; use Core\Driver\SQL\PostgreSQL; use Core\Driver\SQL\SQL; -use Core\External\PHPMailer\Exception; +use Exception; class DateAdd extends Expression { diff --git a/Core/Driver/SQL/Expression/DateSub.class.php b/Core/Driver/SQL/Expression/DateSub.class.php index d953234..1235c99 100644 --- a/Core/Driver/SQL/Expression/DateSub.class.php +++ b/Core/Driver/SQL/Expression/DateSub.class.php @@ -6,7 +6,7 @@ use Core\Driver\SQL\Column\Column; use Core\Driver\SQL\MySQL; use Core\Driver\SQL\PostgreSQL; use Core\Driver\SQL\SQL; -use Core\External\PHPMailer\Exception; +use Exception; class DateSub extends Expression { diff --git a/Core/Driver/SQL/MySQL.class.php b/Core/Driver/SQL/MySQL.class.php index adf5f8b..0300a6b 100644 --- a/Core/Driver/SQL/MySQL.class.php +++ b/Core/Driver/SQL/MySQL.class.php @@ -375,7 +375,10 @@ class MySQL extends SQL { list ($name, $alias) = $parts; return "`$name` $alias"; } else { - return "`$table`"; + $parts = explode(".", $table); + return implode(".", array_map(function ($n) { + return "`$n`"; + }, $parts)); } } } @@ -484,6 +487,27 @@ class MySQL extends SQL { return $res && $res[0]["count"] > 0; } + + public function listTables(): ?array { + $tableSchema = $this->connectionData->getProperty("database"); + $res = $this->select("TABLE_NAME") + ->from("information_schema.TABLES") + ->where(new Compare("TABLE_SCHEMA", $tableSchema, "=", true)) + ->where(new CondLike(new Column("TABLE_TYPE"), "BASE TABLE")) + ->execute(); + + if ($res !== false) { + $tableNames = []; + + foreach ($res as $row) { + $tableNames[] = $row["TABLE_NAME"]; + } + + return $tableNames; + } + + return null; + } } class RowIteratorMySQL extends RowIterator { diff --git a/Core/Driver/SQL/PostgreSQL.class.php b/Core/Driver/SQL/PostgreSQL.class.php index fe2527c..832adac 100644 --- a/Core/Driver/SQL/PostgreSQL.class.php +++ b/Core/Driver/SQL/PostgreSQL.class.php @@ -334,7 +334,10 @@ class PostgreSQL extends SQL { list ($name, $alias) = $parts; return "\"$name\" $alias"; } else { - return "\"$table\""; + $parts = explode(".", $table); + return implode(".", array_map(function ($n) { + return "\"$n\""; + }, $parts)); } } } @@ -463,6 +466,28 @@ class PostgreSQL extends SQL { return $res && $res[0]["count"] > 0; } + + + + public function listTables(): ?array { + $tableSchema = $this->connectionData->getProperty("database"); + $res = $this->select("tablename") + ->from("pg_tables") + ->where(new Compare("schemaname", $tableSchema)) + ->execute(); + + if ($res !== false) { + $tableNames = []; + + foreach ($res as $row) { + $tableNames[] = $row["tablename"]; + } + + return $tableNames; + } + + return null; + } } class RowIteratorPostgreSQL extends RowIterator { diff --git a/Core/Driver/SQL/Query/CreateTrigger.class.php b/Core/Driver/SQL/Query/CreateTrigger.class.php index dbb1289..1e2c5e1 100644 --- a/Core/Driver/SQL/Query/CreateTrigger.class.php +++ b/Core/Driver/SQL/Query/CreateTrigger.class.php @@ -2,7 +2,10 @@ namespace Core\Driver\SQL\Query; +use Core\Driver\SQL\MySQL; +use Core\Driver\SQL\PostgreSQL; use Core\Driver\SQL\SQL; +use Exception; class CreateTrigger extends Query { @@ -11,6 +14,9 @@ class CreateTrigger extends Query { private string $event; private string $tableName; private array $parameters; + + private bool $ifNotExist; + private ?CreateProcedure $procedure; public function __construct(SQL $sql, string $triggerName) { @@ -21,6 +27,7 @@ class CreateTrigger extends Query { $this->event = ""; $this->parameters = []; $this->procedure = null; + $this->ifNotExist = false; } public function before(): CreateTrigger { @@ -28,6 +35,11 @@ class CreateTrigger extends Query { return $this; } + public function onlyIfNotExist(): CreateTrigger { + $this->ifNotExist = true; + return $this; + } + public function after(): CreateTrigger { $this->time = "AFTER"; return $this; @@ -70,7 +82,20 @@ class CreateTrigger extends Query { $tableName = $this->sql->tableName($this->getTable()); $params = array(); - $query = "CREATE TRIGGER $name $time $event ON $tableName FOR EACH ROW "; + + if ($this->sql instanceof MySQL) { + $query = "CREATE TRIGGER"; + if ($this->ifNotExist) { + $query .= " IF NOT EXISTS"; + } + } else if ($this->sql instanceof PostgreSQL) { + $ifNotExists = $this->ifNotExist ? " OR REPLACE" : ""; + $query = "CREATE$ifNotExists TRIGGER"; + } else { + throw new Exception("CreateTrigger Not implemented for driver type: " . get_class($this->sql)); + } + + $query .= " $name $time $event ON $tableName FOR EACH ROW "; $triggerBody = $this->sql->createTriggerBody($this, $this->parameters); if ($triggerBody === null) { return null; diff --git a/Core/Driver/SQL/SQL.class.php b/Core/Driver/SQL/SQL.class.php index 714c698..39ba926 100644 --- a/Core/Driver/SQL/SQL.class.php +++ b/Core/Driver/SQL/SQL.class.php @@ -130,6 +130,8 @@ abstract class SQL { // Schema public abstract function tableExists(string $tableName): bool; + public abstract function listTables(): ?array; + /** * @param Query $query * @param int $fetchType diff --git a/Core/Objects/Context.class.php b/Core/Objects/Context.class.php index 334e3af..3f84a6a 100644 --- a/Core/Objects/Context.class.php +++ b/Core/Objects/Context.class.php @@ -125,10 +125,14 @@ class Context { public function parseCookies(): void { - if ($this->sql) { - if (isset($_COOKIE['session']) && is_string($_COOKIE['session']) && !empty($_COOKIE['session'])) { - $this->loadSession($_COOKIE['session']); - } + $settings = $this->getSettings(); + if (!$settings->isInstalled()) { + // we cannot process user sessions or localization yet. + return; + } + + if (isset($_COOKIE['session']) && is_string($_COOKIE['session']) && !empty($_COOKIE['session'])) { + $this->loadSession($_COOKIE['session']); } // set language by priority: 1. GET parameter, 2. cookie, 3. user's settings, 4. accept-language header diff --git a/Core/Objects/DatabaseEntity/Controller/DatabaseEntityHandler.php b/Core/Objects/DatabaseEntity/Controller/DatabaseEntityHandler.php index 94b7cfe..49466e0 100644 --- a/Core/Objects/DatabaseEntity/Controller/DatabaseEntityHandler.php +++ b/Core/Objects/DatabaseEntity/Controller/DatabaseEntityHandler.php @@ -713,13 +713,13 @@ class DatabaseEntityHandler implements Persistable { return $res; } - public function getCreateQueries(SQL $sql): array { + public function getCreateQueries(SQL $sql, bool $canExist = false): array { $queries = []; $table = $this->getTableName(); // Create Table - $queries[] = $this->getTableQuery($sql); + $queries[] = $this->getTableQuery($sql, $canExist); // pre defined values $getPredefinedValues = $this->entityClass->getMethod("getPredefinedValues"); @@ -733,43 +733,64 @@ class DatabaseEntityHandler implements Persistable { $entityLogConfig = $entityLogConfig->getValue(); if (isset($entityLogConfig["insert"]) && $entityLogConfig["insert"] === true) { - $queries[] = $sql->createTrigger("${table}_trg_insert") + $trigger = $sql->createTrigger("${table}_trg_insert") ->after()->insert($table) ->exec(new CreateProcedure($sql, "InsertEntityLog"), [ "tableName" => new CurrentTable(), "entityId" => new CurrentColumn("id"), "lifetime" => $entityLogConfig["lifetime"] ?? 90, ]); + + if ($canExist) { + $trigger->onlyIfNotExist(); + } + + $queries[] = $trigger; } if (isset($entityLogConfig["update"]) && $entityLogConfig["update"] === true) { - $queries[] = $sql->createTrigger("${table}_trg_update") + $trigger = $sql->createTrigger("${table}_trg_update") ->after()->update($table) ->exec(new CreateProcedure($sql, "UpdateEntityLog"), [ "tableName" => new CurrentTable(), "entityId" => new CurrentColumn("id"), ]); + + if ($canExist) { + $trigger->onlyIfNotExist(); + } + + $queries[] = $trigger; } if (isset($entityLogConfig["delete"]) && $entityLogConfig["delete"] === true) { - $queries[] = $sql->createTrigger("${table}_trg_delete") + $trigger = $sql->createTrigger("${table}_trg_delete") ->after()->delete($table) ->exec(new CreateProcedure($sql, "DeleteEntityLog"), [ "tableName" => new CurrentTable(), "entityId" => new CurrentColumn("id"), ]); + + if ($canExist) { + $trigger->onlyIfNotExist(); + } + + $queries[] = $trigger; } return $queries; } - public function getTableQuery(SQL $sql): CreateTable { + public function getTableQuery(SQL $sql, bool $canExist = false): CreateTable { $query = $sql->createTable($this->tableName) - ->onlyIfNotExists() ->addSerial("id") ->primaryKey("id"); + if ($canExist) { + $query->onlyIfNotExists(); + } + foreach ($this->columns as $column) { $query->addColumn($column); } diff --git a/Core/core.php b/Core/core.php index b69730f..4041284 100644 --- a/Core/core.php +++ b/Core/core.php @@ -10,7 +10,7 @@ if (is_file($autoLoad)) { require_once $autoLoad; } -const WEBBASE_VERSION = "2.4.3"; +const WEBBASE_VERSION = "2.4.4"; spl_autoload_extensions(".php"); spl_autoload_register(function ($class) { diff --git a/test/DatabaseEntity.test.php b/test/DatabaseEntity.test.php index 016e0c8..61ba1bb 100644 --- a/test/DatabaseEntity.test.php +++ b/test/DatabaseEntity.test.php @@ -1,5 +1,7 @@ assertTrue(self::$SQL->drop(self::$HANDLER->getTableName())->execute()); } + + public function testTableNames() { + $sql = self::$SQL; + $this->assertEquals("TestEntity", TestEntity::getHandler($sql, null, true)->getTableName()); + $this->assertEquals("TestEntityInherit", TestEntityInherit::getHandler($sql, null, true)->getTableName()); + $this->assertEquals("TestEntityInherit", OverrideNameSpace\TestEntityInherit::getHandler($sql, null, true)->getTableName()); + } + + public function testCreateQueries() { + $queries = []; + $entities = [TestEntity::class, TestEntityInherit::class, OverrideNameSpace\TestEntityInherit::class]; + \Core\Configuration\CreateDatabase::createEntityQueries(self::$SQL, $entities, $queries); + $this->assertCount(2, $queries); + + $tables = []; + foreach ($queries as $query) { + $this->assertInstanceOf(CreateTable::class, $query); + $tables[] = $query->getTableName(); + } + + $this->assertEquals(["TestEntity", "TestEntityInherit"], $tables); + } } class TestEntity extends DatabaseEntity { @@ -109,4 +133,16 @@ class TestEntity extends DatabaseEntity { public float $d; public \DateTime $e; public ?int $f; -} \ No newline at end of file +} + +class TestEntityInherit extends DatabaseEntity { + public TestEntity $rel; +} + +} + +namespace OverrideNameSpace { + class TestEntityInherit extends \TestEntityInherit { + public int $new; + } +}