diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index abaa26b99e..0ac0e04b8c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,9 @@ jobs: - type: postgresql version: '13' port: 5432 + - type: sqlite + version: '3' + port: 0 fail-fast: false name: PHP ${{ matrix.php-version }} - ${{ matrix.database.type }} ${{ matrix.database.version }} @@ -84,7 +87,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - extensions: bcmath, curl, gd, iconv, mysqli, pdo_mysql, pdo_pgsql, pgsql, soap, zip, fileinfo, openssl, simplexml, mbstring, intl + extensions: bcmath, curl, gd, iconv, mysqli, pdo_mysql, pdo_pgsql, pdo_sqlite, pgsql, soap, zip, fileinfo, openssl, simplexml, mbstring, intl coverage: xdebug tools: composer:v2 env: @@ -110,10 +113,13 @@ jobs: echo "Testing ${{ matrix.database.type }} ${{ matrix.database.version }} connection..." mysql --host=${{ env.DB_HOST }} --user=${{ env.DB_USER }} --password=${{ env.DB_PASS }} -e "SHOW DATABASES;" mysql --host=${{ env.DB_HOST }} --user=${{ env.DB_USER }} --password=${{ env.DB_PASS }} -e "SELECT VERSION();" - else + elif [ "${{ matrix.database.type }}" = "postgresql" ]; then echo "Testing PostgreSQL ${{ matrix.database.version }} connection..." PGPASSWORD=${{ env.DB_PASS }} psql -h ${{ env.DB_HOST }} -U ${{ env.DB_USER }} -d ${{ env.DB_NAME }} -c "\l" PGPASSWORD=${{ env.DB_PASS }} psql -h ${{ env.DB_HOST }} -U ${{ env.DB_USER }} -d ${{ env.DB_NAME }} -c "SELECT version();" + else + echo "Testing SQLite ${{ matrix.database.version }} support..." + php -r 'exit(in_array("sqlite", PDO::getAvailableDrivers(), true) ? 0 : 1);' fi - name: Create application config @@ -140,7 +146,7 @@ jobs: define('FS_DISABLE_ADD_PLUGINS', false); define('FS_DISABLE_RM_PLUGINS', false); EOF - else + elif [ "${{ matrix.database.type }}" = "postgresql" ]; then cat > config.php << 'EOF' config.php << 'EOF' + * @author Jose Antonio Cuello Principal @@ -84,6 +85,10 @@ public function __construct() self::$engine = new PostgresqlEngine(); break; + case 'sqlite': + self::$engine = new SqliteEngine(); + break; + default: self::$engine = new MysqlEngine(); break; diff --git a/Core/Base/DataBase/SqliteEngine.php b/Core/Base/DataBase/SqliteEngine.php new file mode 100644 index 0000000000..759b03477e --- /dev/null +++ b/Core/Base/DataBase/SqliteEngine.php @@ -0,0 +1,354 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Base\DataBase; + +use Exception; +use FacturaScripts\Core\KernelException; +use PDO; +use PDOException; +use FacturaScripts\Core\Tools; + +/** + * Class to connect with SQLite. + * + * @author Carlos García Gómez + * @author Jose Antonio Cuello Principal + */ +class SqliteEngine extends DataBaseEngine +{ + /** + * Link to the SQL statements for the connected database. + * + * @var DataBaseQueries + */ + private $utilsSQL; + + public function __construct() + { + parent::__construct(); + $this->utilsSQL = new SqliteQueries(); + } + + public function beginTransaction($link): bool + { + return $link->inTransaction() ? true : $link->beginTransaction(); + } + + public function castInteger($link, $column): string + { + return 'CAST(' . $this->escapeColumn($link, $column) . ' AS INTEGER)'; + } + + public function close($link): bool + { + if ($link->inTransaction()) { + $link->rollBack(); + } + + return true; + } + + public function random(): string + { + return 'RANDOM()'; + } + + public function columnFromData($colData): array + { + $result = [ + 'name' => $colData['name'], + 'type' => strtolower($colData['type'] ?? 'text'), + 'default' => $this->normalizeDefaultValue($colData['dflt_value'] ?? null), + 'is_nullable' => !empty($colData['notnull']) ? 'NO' : 'YES', + 'extra' => null, + ]; + + if (!empty($colData['pk']) && $result['type'] === 'integer') { + $result['type'] = 'serial'; + $result['is_nullable'] = 'NO'; + } + + return $result; + } + + public function commit($link): bool + { + return false === $link->inTransaction() || $link->commit(); + } + + public function compareDataTypes($dbType, $xmlType): bool + { + if (parent::compareDataTypes($dbType, $xmlType)) { + return true; + } + + $dbType = strtolower((string)$dbType); + $xmlType = strtolower((string)$xmlType); + + if ($dbType === $xmlType) { + return true; + } + + if ($dbType === 'integer' && (in_array($xmlType, ['int', 'integer', 'int2', 'int4', 'int8'], true) || $xmlType === 'boolean')) { + return true; + } + + if ($dbType === 'real' && $xmlType === 'double precision') { + return true; + } + + if (str_starts_with($dbType, 'varchar(') && str_starts_with($xmlType, 'character varying(')) { + return substr($dbType, 8, -1) === substr($xmlType, 18, -1); + } + + if (str_starts_with($dbType, 'character varying(') && str_starts_with($xmlType, 'character varying(')) { + return substr($dbType, 18, -1) === substr($xmlType, 18, -1); + } + + return false; + } + + public function connect(&$error) + { + if (false === class_exists('PDO') || false === in_array('sqlite', PDO::getAvailableDrivers(), false)) { + $error = $this->i18n->trans('php-extension-not-found', ['%extension%' => 'pdo_sqlite']); + throw new KernelException('DatabaseError', $error); + } + + $database = self::getDatabasePath(Tools::config('db_name')); + $directory = dirname($database); + if ($database !== ':memory:' && false === is_dir($directory) && false === @mkdir($directory, 0755, true)) { + $error = 'Unable to create SQLite directory: ' . $directory; + throw new KernelException('DatabaseError', $error); + } + + try { + $result = new PDO('sqlite:' . $database); + $result->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $result->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + $result->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); + $result->sqliteCreateFunction('regexp', function ($pattern, $value) { + if ($value === null) { + return 0; + } + + $regex = '/' . str_replace('/', '\/', (string)$pattern) . '/u'; + return @preg_match($regex, (string)$value) ? 1 : 0; + }, 2); + $result->exec('PRAGMA foreign_keys = ' . (Tools::config('db_foreign_keys', true) ? 'ON' : 'OFF') . ';'); + return $result; + } catch (PDOException $err) { + $error = $err->getMessage(); + $this->lastErrorMsg = $error; + throw new KernelException('DatabaseError', $error); + } + } + + public function errorMessage($link): string + { + if ($this->lastErrorMsg !== '') { + return $this->lastErrorMsg; + } + + $error = $link->errorInfo(); + return isset($error[2]) ? (string)$error[2] : ''; + } + + public function escapeColumn($link, $name): string + { + if (strpos($name, '.') !== false) { + $parts = explode('.', $name); + return '"' . implode('"."', $parts) . '"'; + } + + return '"' . $name . '"'; + } + + public function escapeString($link, $str): string + { + $quoted = $link->quote($str); + return substr($quoted, 1, -1); + } + + public function exec($link, $sql): bool + { + $this->lastErrorMsg = ''; + + try { + $sql = trim($sql); + if ($sql === '') { + return true; + } + + // si no hay punto y coma interior, ejecutamos directamente + $stripped = rtrim($sql, '; '); + if (strpos($stripped, ';') === false) { + $link->exec($stripped); + return true; + } + + foreach ($this->splitStatements($sql) as $statement) { + if ($statement === '') { + continue; + } + + $link->exec($statement); + } + + return true; + } catch (Exception $err) { + $this->lastErrorMsg = $err->getMessage(); + return false; + } + } + + public function getSQL() + { + return $this->utilsSQL; + } + + public function inTransaction($link): bool + { + return $link->inTransaction(); + } + + public function listTables($link): array + { + $tables = []; + foreach ($this->select($link, "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name ASC;") as $row) { + $tables[] = $row['name']; + } + + return $tables; + } + + public function rollback($link): bool + { + return false === $link->inTransaction() || $link->rollBack(); + } + + public function select($link, $sql): array + { + $this->lastErrorMsg = ''; + + try { + $statement = $link->query($sql); + return false === $statement ? [] : $statement->fetchAll(PDO::FETCH_ASSOC); + } catch (Exception $err) { + $this->lastErrorMsg = $err->getMessage(); + return []; + } + } + + public function version($link): string + { + $data = $this->select($link, 'SELECT sqlite_version() AS version;'); + return empty($data) ? 'SQLITE' : 'SQLITE ' . $data[0]['version']; + } + + public static function getDatabasePath(?string $database): string + { + if (empty($database)) { + return Tools::folder('MyFiles', 'facturascripts.sqlite'); + } + + if (strpos($database, "\0") !== false) { + throw new KernelException('DatabaseError', 'Invalid SQLite database path.'); + } + + if ($database === ':memory:' || str_starts_with($database, DIRECTORY_SEPARATOR) || preg_match('/^[A-Za-z]:[\\\\\\/]/', $database)) { + return $database; + } + + if (str_contains($database, '..')) { + throw new KernelException('DatabaseError', 'Invalid SQLite database path.'); + } + + return FS_FOLDER . DIRECTORY_SEPARATOR . ltrim($database, DIRECTORY_SEPARATOR); + } + + private function normalizeDefaultValue($value) + { + if ($value === null) { + return null; + } + + $value = trim((string)$value); + if ($value === 'NULL') { + return null; + } + + if ((str_starts_with($value, "'") && str_ends_with($value, "'")) || (str_starts_with($value, '"') && str_ends_with($value, '"'))) { + return substr($value, 1, -1); + } + + return strtolower($value) === 'false' || strtolower($value) === 'true' ? strtolower($value) : $value; + } + + private function splitStatements(string $sql): array + { + $statements = []; + $statement = ''; + $inSingleQuote = false; + $inDoubleQuote = false; + $length = strlen($sql); + + for ($i = 0; $i < $length; $i++) { + $char = $sql[$i]; + $statement .= $char; + + if ($char === "'" && false === $inDoubleQuote) { + if ($inSingleQuote && $i + 1 < $length && $sql[$i + 1] === "'") { + $statement .= $sql[++$i]; + continue; + } + + $inSingleQuote = false === $inSingleQuote; + continue; + } + + if ($char === '"' && false === $inSingleQuote) { + if ($inDoubleQuote && $i + 1 < $length && $sql[$i + 1] === '"') { + $statement .= $sql[++$i]; + continue; + } + + $inDoubleQuote = false === $inDoubleQuote; + continue; + } + + if ($char === ';' && false === $inSingleQuote && false === $inDoubleQuote) { + $statement = trim($statement); + if ($statement !== '') { + $statements[] = rtrim($statement, ';'); + } + + $statement = ''; + } + } + + $statement = trim($statement); + if ($statement !== '') { + $statements[] = rtrim($statement, ';'); + } + + return $statements; + } +} diff --git a/Core/Base/DataBase/SqliteQueries.php b/Core/Base/DataBase/SqliteQueries.php new file mode 100644 index 0000000000..eb33c7cd86 --- /dev/null +++ b/Core/Base/DataBase/SqliteQueries.php @@ -0,0 +1,238 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Base\DataBase; + +use FacturaScripts\Core\Tools; + +/** + * Class that gathers all the needed SQL sentences by the database engine. + * + * @author Carlos García Gómez + * @author Jose Antonio Cuello Principal + */ +class SqliteQueries implements DataBaseQueries +{ + public function sql2Int(string $colName): string + { + return 'CAST(' . $colName . ' as INTEGER)'; + } + + public function sqlAddConstraint(string $tableName, string $constraintName, string $sql): string + { + return ''; + } + + public function sqlAddIndex(string $tableName, string $indexName, string $columns): string + { + return 'CREATE INDEX ' . $indexName . ' ON ' . $tableName . ' (' . $columns . ');'; + } + + public function sqlAlterAddColumn(string $tableName, array $colData): string + { + return 'ALTER TABLE ' . $tableName . ' ADD COLUMN ' . $colData['name'] . ' ' + . $this->getTypeAndConstraints($colData, false) . ';'; + } + + public function sqlAlterColumnDefault(string $tableName, array $colData): string + { + return ''; + } + + public function sqlAlterColumnNull(string $tableName, array $colData): string + { + return ''; + } + + public function sqlAlterModifyColumn(string $tableName, array $colData): string + { + return ''; + } + + public function sqlColumns(string $tableName): string + { + return 'PRAGMA table_info("' . $tableName . '");'; + } + + public function sqlConstraints(string $tableName): string + { + return "SELECT 'PRIMARY' AS name, 'PRIMARY KEY' AS type" + . ' FROM pragma_table_info(' . $this->quote($tableName) . ')' + . ' WHERE pk > 0 LIMIT 1;'; + } + + public function sqlConstraintsExtended(string $tableName): string + { + return "SELECT 'PRIMARY' AS name, 'PRIMARY KEY' AS type," + . ' name AS column_name,' + . ' NULL AS foreign_table_name,' + . ' NULL AS foreign_column_name,' + . ' NULL AS on_update,' + . ' NULL AS on_delete' + . ' FROM pragma_table_info(' . $this->quote($tableName) . ')' + . ' WHERE pk > 0;'; + } + + public function sqlCreateTable(string $tableName, array $columns, array $constraints, array $indexes): string + { + $fields = ''; + foreach ($columns as $col) { + $fields .= ', "' . $col['name'] . '" ' . $this->getTypeAndConstraints($col); + } + + return 'CREATE TABLE ' . $tableName . ' (' . substr($fields, 2) + . $this->buildTableConstraints($constraints, $columns) . ');' + . $this->sqlTableIndexes($tableName, $indexes); + } + + public function sqlDropConstraint(string $tableName, array $colData): string + { + return ''; + } + + public function sqlDropIndex(string $tableName, array $colData): string + { + return 'DROP INDEX IF EXISTS ' . $colData['name'] . ';'; + } + + public function sqlDropTable(string $tableName): string + { + return 'DROP TABLE IF EXISTS ' . $tableName . ';'; + } + + public function sqlIndexes(string $tableName): string + { + return 'SELECT il.name AS key_name, ii.name AS column_name' + . ' FROM pragma_index_list(' . $this->quote($tableName) . ') il' + . ' JOIN pragma_index_info(il.name) ii' + . " WHERE il.origin != 'pk'" + . ' ORDER BY il.name ASC, ii.seqno ASC;'; + } + + public function sqlLastValue(): string + { + return 'SELECT last_insert_rowid() as num;'; + } + + public function sqlRenameColumn(string $tableName, string $old_column, string $new_column): string + { + return 'ALTER TABLE ' . $tableName . ' RENAME COLUMN ' . $old_column . ' TO ' . $new_column . ';'; + } + + public function sqlTableConstraints(array $xmlCons): string + { + return $this->buildTableConstraints($xmlCons, []); + } + + private function getConstraints(array $colData, bool $includeDefault = true): string + { + $result = ''; + + if (($colData['null'] ?? 'YES') === 'NO') { + $result .= ' NOT NULL'; + } + + if (false === $includeDefault) { + return $result; + } + + if ($colData['default'] === null && ($colData['null'] ?? 'YES') !== 'NO') { + return $result . ' DEFAULT NULL'; + } + + if ($colData['default'] !== '') { + return $result . ' DEFAULT ' . $this->normalizeDefault($colData['default']); + } + + return $result; + } + + private function getTypeAndConstraints(array $colData, bool $includeDefault = true): string + { + switch ($colData['type']) { + case 'serial': + return 'INTEGER PRIMARY KEY AUTOINCREMENT'; + + default: + return $colData['type'] . $this->getConstraints($colData, $includeDefault); + } + } + + private function normalizeDefault($default): string + { + if ($default === null) { + return 'NULL'; + } + + if (in_array($default, ['CURRENT_DATE', 'CURRENT_TIMESTAMP', 'NULL'], true)) { + return (string)$default; + } + + if (in_array($default, ['false', 'true'], true)) { + return strtoupper((string)$default); + } + + return (string)$default; + } + + private function quote(string $value): string + { + return "'" . str_replace("'", "''", $value) . "'"; + } + + private function buildTableConstraints(array $xmlCons, array $columns): string + { + $hasSerialPrimary = false; + foreach ($columns as $column) { + if ($column['type'] === 'serial') { + $hasSerialPrimary = true; + break; + } + } + + $sql = ''; + foreach ($xmlCons as $res) { + $value = strtolower($res['constraint']); + + if ($hasSerialPrimary && false !== strpos($value, 'primary key')) { + continue; + } + + if ( + false !== strpos($value, 'primary key') || + false !== strpos($value, 'unique') || + (false !== strpos($value, 'foreign key') && Tools::config('db_foreign_keys', true)) + ) { + $sql .= ', CONSTRAINT ' . $res['name'] . ' ' . $res['constraint']; + } + } + + return $sql; + } + + private function sqlTableIndexes(string $tableName, array $xmlIndexes): string + { + $sql = ''; + foreach ($xmlIndexes as $idx) { + $sql .= ' CREATE INDEX fs_' . $idx['name'] . ' ON ' . $tableName . ' (' . $idx['columns'] . ');'; + } + + return $sql; + } +} diff --git a/Core/Controller/Installer.php b/Core/Controller/Installer.php index e66e4732d3..1fce445ce2 100644 --- a/Core/Controller/Installer.php +++ b/Core/Controller/Installer.php @@ -28,7 +28,9 @@ use FacturaScripts\Core\Plugins; use FacturaScripts\Core\Request; use FacturaScripts\Core\Tools; +use FacturaScripts\Core\Base\DataBase\SqliteEngine; use mysqli; +use PDO; class Installer implements ControllerInterface { @@ -151,6 +153,9 @@ private function createDataBase(): bool case 'postgresql': return $this->testPostgresql($dbData); + + case 'sqlite': + return $this->testSqlite($dbData); } Tools::log()->critical('cant-connect-database'); @@ -432,6 +437,37 @@ private function testPostgresql(array $dbData): bool return false; } + private function testSqlite(array $dbData): bool + { + if (false === class_exists('PDO') || false === in_array('sqlite', PDO::getAvailableDrivers(), false)) { + Tools::log()->critical('php-extension-not-found', ['%extension%' => 'pdo_sqlite']); + return false; + } + + try { + $database = SqliteEngine::getDatabasePath($dbData['name']); + } catch (KernelException $e) { + Tools::log()->critical($e->getMessage()); + return false; + } + + $directory = dirname($database); + if ($database !== ':memory:' && false === is_dir($directory) && false === @mkdir($directory, 0755, true)) { + Tools::log()->critical('cant-create-folder', ['%folderName%' => $directory]); + return false; + } + + try { + $connection = new PDO('sqlite:' . $database); + $connection->exec('PRAGMA foreign_keys = ON;'); + return true; + } catch (Exception $e) { + Tools::log()->critical('cant-connect-database'); + Tools::log()->critical($e->getMessage()); + return false; + } + } + private function versionPostgres($connection): float { $version = pg_version($connection); diff --git a/Core/Lib/Import/CSVImport.php b/Core/Lib/Import/CSVImport.php index da5327f224..95f3b5e331 100644 --- a/Core/Lib/Import/CSVImport.php +++ b/Core/Lib/Import/CSVImport.php @@ -167,6 +167,7 @@ private static function insertOnDuplicateSql($sql, $csv): string break; case 'postgresql': + case 'sqlite': $sql .= ' ON CONFLICT (' . $csv->titles[0] . ') DO UPDATE SET ' diff --git a/Core/Model/AttachedFile.php b/Core/Model/AttachedFile.php index d237367acd..81065b72ab 100644 --- a/Core/Model/AttachedFile.php +++ b/Core/Model/AttachedFile.php @@ -246,6 +246,13 @@ protected function getNextCode(): int return max($this->newCode(), $row['nextval']); } break; + + case 'sqlite': + $sql = "SELECT seq + 1 AS nextid FROM sqlite_sequence WHERE name = '" . static::tableName() . "';"; + foreach (static::db()->select($sql) as $row) { + return max($this->newCode(), $row['nextid']); + } + break; } return $this->newCode(); diff --git a/Core/Template/ModelClass.php b/Core/Template/ModelClass.php index 46bee71114..73216cda48 100644 --- a/Core/Template/ModelClass.php +++ b/Core/Template/ModelClass.php @@ -630,6 +630,9 @@ public function test(): bool } elseif (null === $value['default'] && $value['is_nullable'] === 'NO' && $this->{$key} === null) { Tools::log()->warning('field-can-not-be-null', ['%fieldName%' => $key, '%tableName%' => static::tableName()]); $return = false; + } elseif ($this->fieldValueExceedsMaxLength($value, $this->{$key})) { + Tools::log()->warning('value-too-long'); + $return = false; } } if (false === $return) { @@ -791,6 +794,19 @@ private function getBoolValueForField(array $field, $value): ?bool return in_array(strtolower($value), ['true', 't', '1'], false); } + private function fieldValueExceedsMaxLength(array $field, $value): bool + { + if ($value === null || is_array($value) || is_object($value)) { + return false; + } + + if (preg_match('/^(?:character varying|varchar|char)\((\d+)\)$/i', (string)$field['type'], $matches) !== 1) { + return false; + } + + return strlen((string)$value) > (int)$matches[1]; + } + private function getIntegerValueForField(array $field, $value): ?int { if (is_numeric($value)) { diff --git a/Core/View/Installer/Install.html.twig b/Core/View/Installer/Install.html.twig index 36975ca524..89c9f263a6 100644 --- a/Core/View/Installer/Install.html.twig +++ b/Core/View/Installer/Install.html.twig @@ -38,6 +38,12 @@ $('#mysql_socket_div').hide(); $('#pgsql_ssl_div').show(); $('#pgsql_endpoint').show(); + } else if (dbType === 'sqlite') { + $('#db_port').val(0); + $('#db_user').val(''); + $('#mysql_socket_div').hide(); + $('#pgsql_ssl_div').hide(); + $('#pgsql_endpoint').hide(); } } @@ -204,9 +210,15 @@ {% if fsc.db_type == 'mysql' %} + + {% elseif fsc.db_type == 'sqlite' %} + + + {% else %} + {% endif %} @@ -233,7 +245,7 @@ + class="form-control" pattern="(?!.*\.\.)[A-Za-z0-9_./:-]+" required/> diff --git a/Test/Core/DbUpdaterTest.php b/Test/Core/DbUpdaterTest.php index 46b075e661..b6cdcadb27 100644 --- a/Test/Core/DbUpdaterTest.php +++ b/Test/Core/DbUpdaterTest.php @@ -99,6 +99,10 @@ public function testCanCreateAndDropTable(): void public function testCanAddColumnsAndConstraintsToTable(): void { + if ($this->db()->type() === 'sqlite') { + $this->markTestSkipped('SQLite support keeps schema updates limited to create, rename and index changes.'); + } + // creamos la tabla $table_name = 'test_table'; $file_path = Tools::folder('Test', '__files', $table_name . '.xml'); @@ -134,6 +138,10 @@ public function testCanAddColumnsAndConstraintsToTable(): void public function testCanUpdateTableColumnNullAndDefault(): void { + if ($this->db()->type() === 'sqlite') { + $this->markTestSkipped('SQLite support keeps schema updates limited to create, rename and index changes.'); + } + // creamos la tabla $table_name = 'test_table'; $file_path = Tools::folder('Test', '__files', $table_name . '.xml'); diff --git a/Test/Core/Lib/Import/CSVImportTest.php b/Test/Core/Lib/Import/CSVImportTest.php index abc77a783f..4f6d25f1f6 100644 --- a/Test/Core/Lib/Import/CSVImportTest.php +++ b/Test/Core/Lib/Import/CSVImportTest.php @@ -106,7 +106,7 @@ public function testUpdateTableSQL(): void $dbType = Tools::config('db_type'); if ($dbType === 'mysql') { $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', strtoupper($sql), 'Debería contener ON DUPLICATE KEY UPDATE para MySQL'); - } elseif ($dbType === 'postgresql') { + } elseif (in_array($dbType, ['postgresql', 'sqlite'], true)) { $this->assertStringContainsString('ON CONFLICT', strtoupper($sql), 'Debería contener ON CONFLICT para PostgreSQL'); } } @@ -162,7 +162,7 @@ public function testImportFileSQLWithUpdate(): void $dbType = Tools::config('db_type'); if ($dbType === 'mysql') { $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', strtoupper($sql)); - } elseif ($dbType === 'postgresql') { + } elseif (in_array($dbType, ['postgresql', 'sqlite'], true)) { $this->assertStringContainsString('ON CONFLICT', strtoupper($sql)); } } diff --git a/Test/Core/SqliteSupportTest.php b/Test/Core/SqliteSupportTest.php new file mode 100644 index 0000000000..dc167901e9 --- /dev/null +++ b/Test/Core/SqliteSupportTest.php @@ -0,0 +1,109 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Test\Core; + +use FacturaScripts\Core\Base\DataBase; +use FacturaScripts\Core\DbUpdater; +use FacturaScripts\Core\Model\LogMessage; +use FacturaScripts\Core\Tools; +use FacturaScripts\Test\Traits\LogErrorsTrait; +use PHPUnit\Framework\TestCase; + +final class SqliteSupportTest extends TestCase +{ + use LogErrorsTrait; + + /** @var DataBase */ + private $db; + + public function testConnectionAndVersion(): void + { + if ($this->db()->type() !== 'sqlite') { + $this->markTestSkipped('SQLite-only test.'); + } + + $this->assertTrue($this->db()->connect()); + $version = $this->db()->select('SELECT sqlite_version() AS version;'); + $this->assertNotEmpty($version); + $this->assertNotEmpty($version[0]['version']); + } + + public function testSchemaCreateAndIndexUpdate(): void + { + if ($this->db()->type() !== 'sqlite') { + $this->markTestSkipped('SQLite-only test.'); + } + + DbUpdater::rebuild(); + DbUpdater::dropTable('test_table'); + + $structure = DbUpdater::readTableXml(Tools::folder('Test', '__files', 'test_table.xml')); + $this->assertTrue(DbUpdater::createTable('test_table', $structure)); + $this->assertTrue($this->db()->tableExists('test_table')); + + DbUpdater::rebuild(); + $updated = DbUpdater::readTableXml(Tools::folder('Test', '__files', 'test_table_update_4.xml')); + $this->assertTrue(DbUpdater::updateTable('test_table', $updated)); + $this->assertCount(1, $this->db()->getIndexes('test_table')); + + $this->assertTrue(DbUpdater::dropTable('test_table')); + } + + public function testSimpleModelCrud(): void + { + if ($this->db()->type() !== 'sqlite') { + $this->markTestSkipped('SQLite-only test.'); + } + + $log = new LogMessage(); + $log->channel = 'sqlite-test'; + $log->level = 'info'; + $log->message = 'created from sqlite test'; + $this->assertTrue($log->save()); + $this->assertNotEmpty($log->id); + + $loaded = new LogMessage(); + $this->assertTrue($loaded->loadFromCode($log->id)); + $this->assertSame('created from sqlite test', $loaded->message); + + $loaded->message = 'updated from sqlite test'; + $this->assertTrue($loaded->save()); + + $reloaded = new LogMessage(); + $this->assertTrue($reloaded->loadFromCode($log->id)); + $this->assertSame('updated from sqlite test', $reloaded->message); + $this->assertTrue($reloaded->delete()); + } + + private function db(): DataBase + { + if (null === $this->db) { + $this->db = new DataBase(); + $this->db->connect(); + } + + return $this->db; + } + + protected function tearDown(): void + { + $this->logErrors(); + } +} diff --git a/Test/Traits/LogErrorsTrait.php b/Test/Traits/LogErrorsTrait.php index 8f1c5dbf9b..f01f9a70bc 100644 --- a/Test/Traits/LogErrorsTrait.php +++ b/Test/Traits/LogErrorsTrait.php @@ -55,7 +55,7 @@ protected function logErrors(bool $force = false): void protected function searchAuditLog(string $modelClass, string $modelCode): bool { foreach (MiniLog::read('audit') as $log) { - if ($log['context']['model-class'] === $modelClass && $log['context']['model-code'] === $modelCode) { + if ($log['context']['model-class'] === $modelClass && (string)$log['context']['model-code'] === $modelCode) { return true; } }