diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml index f3005944..ebfee56c 100644 --- a/.idea/sqldialects.xml +++ b/.idea/sqldialects.xml @@ -7,5 +7,7 @@ + + \ No newline at end of file diff --git a/docs/datetime.md b/docs/datetime.md index 7e9e62d2..a236dc50 100644 --- a/docs/datetime.md +++ b/docs/datetime.md @@ -11,12 +11,13 @@ Generally, we recognize two types of date-time types: The following table presents a matrix of available DB date-time types: -| | Local DateTime
no timezone handling | DateTime
timezone conversion | DateTime
timezone stored | -|-------------|-----------------------------------------|---------------------------------|-----------------------------| -| MySQL | `datetime` | `timestamp` | - | -| Postgres | `timestamp` | `timestamptz` | - | -| SQL Server | `datetime`, `datetime2` | - | `datetimeoffset` | - +| | Local DateTime
no timezone handling | DateTime
timezone conversion | DateTime
timezone stored | +|------------|----------------------------------------|---------------------------------|-----------------------------| +| MySQL | `datetime` | `timestamp` | - | +| Postgres | `timestamp` | `timestamptz` | - | +| SQL Server | `datetime`, `datetime2` | - | `datetimeoffset` | +| Sqlite | - | - | - | +- - **no timezone handling**: database stores the time-stamp and does not do any modification to it; this is the easiest solution, but brings a disadvantage: database cannot exactly diff two time-stamps, i.e. it may produce wrong results because day-light saving shift is needed but db does not know which zone to use for the calculation; - **timezone conversion**: database stores the time-stamp unified in UTC and reads it in connection's timezone; - **timezone stored**: database does not do any conversion, it just stores the timezoned timestamp and returns it back; @@ -25,10 +26,10 @@ Dbal offers a **connection time zone** configuration option (`connectionTz`) tha Dbal comes with two query modifiers: -| Type | Modifier | Description | -|----------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------| +| Type | Modifier | Description | +|----------------|----------|--------------------------------------------------------------------------------------------------------------------------------| | local datetime | `%ldt` | passes DateTime(Interface) object as it is, without any timezone conversion and identification; formerly known as datetime simple (`%dts`) | -| datetime | `%dt` | converts DateTime(Interface) object to connection timezone; | +| datetime | `%dt` | converts DateTime(Interface) object to connection timezone; | --------------- @@ -90,3 +91,36 @@ This will make Dbal fully functional, although some SQL queries and expressions |----------------|------------------|--------------------------------------------------------------------------------------------------------------------------| | local datetime | `datetime` | value is converted into application timezone | | datetime | `datetimeoffset` | value is read with timezone offset and no further modification is done - i.e. no application timezone conversion happens | + +-------------------------- + +### Sqlite + +Sqlite does not have dedicated date/time storage types. Dbal therefore relies on the declared column type and uses a convention for exact timestamps. + +Use `datetime(your_column, 'unixepoch', 'localtime')` to convert stored timestamp to your local time-zone. Read more in the [official documentation](https://sqlite.org/lang_datefunc.html#modifiers). + +##### Writing + +| Type | Modifier | Comment | +|----------------|----------|-------------------------------------------------------------------------------------------------| +| local datetime | `%ldt` | the timezone offset is removed and value is formatted as ISO string without the timezone offset | +| datetime | `%dt` | value is converted to connection timezone and stored as unix timestamp in milliseconds | + +##### Reading + +| Type | Declared Column Type | Comment | +|----------------|------------------------------------------------------------------------------|-------------------------------------------------------------------| +| local datetime | `date`, `datetime`, `time` | built-in aliases supported by the SQLite driver | +| local datetime | `localdate`, `localdatetime`, `localtime` | short aliases if you want to distinguish intent explicitly | +| local datetime | `dbal_local_date`, `dbal_local_datetime`, `dbal_local_time` | recommended explicit Dbal convention for Sqlite schemas | +| datetime | `timestamp`, `unixtimestamp`, `dbal_timestamp` | interpreted as unix timestamp in milliseconds and converted to app timezone | + +##### Detection Notes + +- Sqlite detection is based on the declared column type returned by PDO metadata. +- `dbal_timestamp` is the recommended type name for exact timestamps stored as unix milliseconds. +- `dbal_local_date`, `dbal_local_datetime`, and `dbal_local_time` are the recommended type names for local values stored as strings. +- `dbal_bool` is supported as an explicit boolean declared type. +- Other supported scalar type aliases are standard SQL-style names such as `int`, `integer`, `tinyint`, `smallint`, `bigint`, `real`, `float`, `numeric`, and `decimal`. +- If you use unrecognized custom type names, Dbal will not auto-normalize them. diff --git a/docs/default.md b/docs/default.md index 21a360f9..22c193d6 100644 --- a/docs/default.md +++ b/docs/default.md @@ -6,23 +6,24 @@ Supported platforms: - **MySQL** via `mysqli` or `pdo_mysql` extension, - **Postgres** via `pgsql` or `pdo_pgsql` extension, -- **MS SQL Server** via `sqlsrv` or `pdo_sqlsrv` extension. +- **MS SQL Server** via `sqlsrv` or `pdo_sqlsrv` extension, +- **Sqlite** via `pdo_sqlite` extension. ### Connection The Connection instance is the main access point to the database. Connection's constructor accepts a configuration array. The possible keys depend on the specific driver; some configuration keys are shared for all drivers. To actual list of supported keys are enumerated in PhpDoc comment in driver's source code. -| Key | Description | -|-----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------| -| `driver` | driver name, use `mysqli`, `pgsql`, `sqlsrv`, `pdo_mysql`, `pdo_pgsql`, `pdo_sqlsrv` | -| `host` | database server name | -| `port` | database server port | -| `username` | username for authentication | -| `password` | password for authentication | -| `database` | database name | -| `charset` | charset encoding of the connection | -| `nestedTransactionsWithSavepoint` | boolean which indicates whether use save-points for nested transactions; `true` by default | -| `sqlProcessorFactory` | factory implementing ISqlProcessorFactory interface; use for adding custom modifiers; `null` by default; | +| Key | Description | +|-----------------------------------|-----------------------------------------------------------------------------------------------------------------------| +| `driver` | driver name, use `mysqli`, `pgsql`, `sqlsrv`, `pdo_mysql`, `pdo_pgsql`, `pdo_sqlsrv`, or `pdo_sqlite` | +| `host` | database server name | +| `port` | database server port | +| `username` | username for authentication | +| `password` | password for authentication | +| `database` | database name | +| `charset` | charset encoding of the connection | +| `nestedTransactionsWithSavepoint` | boolean which indicates whether use save-points for nested transactions; `true` by default | +| `sqlProcessorFactory` | factory implementing ISqlProcessorFactory interface; use for adding custom modifiers; `null` by default; | | `connectionTz` | timezone for the connection; pass a timezone name, `auto` or `auto-offset` keyword, see [DateTime TimeZones](datetime) chapter for more info; | | `searchPath` | *PgSQL only*; sets the connection `search_path`; | | `sqlMode` | *MySQL only*; sets the `sql_mode`, `TRADITIONAL` by default; | diff --git a/docs/menu.md b/docs/menu.md index 1920b627..f106bcd4 100644 --- a/docs/menu.md +++ b/docs/menu.md @@ -3,6 +3,7 @@ - [Config with Symfony](config-symfony) - [Modifiers](param-modifiers) - [Result](result) +- [Result Normalization](result-normalization) - [Query Builder](query-builder) - [DateTime](datetime) diff --git a/docs/result-normalization.md b/docs/result-normalization.md new file mode 100644 index 00000000..0721e592 --- /dev/null +++ b/docs/result-normalization.md @@ -0,0 +1,95 @@ +## Result Normalization + +Dbal automatically normalizes selected result values to PHP types based on driver metadata. + +Normalization is enabled by default for every `Nextras\Dbal\Result\Result` instance returned from `Connection::query()`. + +```php +$result = $connection->query('SELECT * FROM events'); + +$result->setValueNormalization(false); // return raw driver values +$result->setValueNormalization(true); // restore default driver normalization +``` + +Normalization is decided per column from metadata reported by the active driver. Dbal does not inspect SQL expressions semantically; it relies on the type information the extension exposes for each selected column. That metadata is not equally detailed across all drivers, and for some queries it may be incomplete or missing entirely. + +If a type is not recognized, Dbal leaves the value unchanged and returns the original driver value. This raw value can differ not only between drivers, but sometimes even between different PHP versions of the same driver, because native extensions and PDO metadata handling can change over time. + +### General Rules + +- Integers are normalized to `int`. +- Floating-point values are normalized to `float` where the driver metadata clearly marks them as floating-point. +- Date and time values are normalized to `Nextras\Dbal\Utils\DateTimeImmutable`. + +### MySQL + +Drivers: `mysqli`, `pdo_mysql`. + +Notes: +- Decimal values are left as strings. +- `timestamp` is treated as an exact timestamp. +- `datetime` and `date` are treated as local values. + +| Column Type in DB | PHP Type | +|----------------------------------------------------|----------------------------------------| +| `BIT`, `TINY`, `SHORT`, `LONG`, `LONGLONG`, `YEAR` | `int` | +| `FLOAT`, `DOUBLE` | `float` | +| `datetime`, `date` | `Nextras\Dbal\Utils\DateTimeImmutable` | +| `timestamp` | `Nextras\Dbal\Utils\DateTimeImmutable` | +| `time` | `DateInterval` | + +### PostgreSQL + +Drivers: `pgsql`, `pdo_pgsql`. + +Notes: +- `numeric` is left as string. +- PostgreSQL date/time values are parsed from textual representation and then converted to the application timezone. +- `pdo_pgsql` leaves `bool` values untouched because PDO already returns a suitable scalar value. + +| Column Type in DB | PHP Type | +|------------------------------------------------------|----------------------------------------| +| `int2`, `int4`, `int8` | `int` | +| `float4`, `float8` | `float` | +| `bool` (`pgsql`) | `bool` | +| `date`, `time`, `timestamp`, `timetz`, `timestamptz` | `Nextras\Dbal\Utils\DateTimeImmutable` | +| `interval` | `DateInterval` | +| `bit`, `varbit` | `int` | +| `bytea` | `string` | + +### SQL Server + +Drivers: `sqlsrv`, `pdo_sqlsrv`. + +Notes: +- `datetimeoffset` keeps the stored offset semantics. +- Decimal and money-like values are left as strings. + +| Column Type in DB | PHP Type | +|----------------------------------------------------------|----------------------------------------| +| integer types | `int` | +| `real` | `float` | +| `bit` | `bool` | +| `date`, `time`, `datetime`, `datetime2`, `smalldatetime` | `Nextras\Dbal\Utils\DateTimeImmutable` | +| `datetimeoffset` | `Nextras\Dbal\Utils\DateTimeImmutable` | + +### Sqlite + +Driver: `pdo_sqlite`. + +Notes: +- SQLite normalization depends on the declared column type name. +- Local datetime aliases are parsed as local values. +- Datetime aliases are interpreted as unix timestamps in milliseconds. +- `%dt` writes unix timestamps in milliseconds. +- `%ldt` writes local string values without timezone offset. +- If you use an unrecognized custom declared type, Dbal leaves the value unchanged. +- Generic SQLite `text` and `varchar` columns are not auto-normalized. + +| Column Type in DB | PHP Type | +|------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------| +| `int`, `integer`, `tinyint`, `smallint`, `mediumint`, `bigint`, `unsigned big int`, `int2`, `int8` | `int` | +| `real`, `double`, `double precision`, `float`, `numeric`, `decimal` | `float` | +| `bool`, `boolean`, `bit`, `dbal_bool` | `bool` | +| `date`, `datetime`, `time`, `localdate`, `localdatetime`, `localtime`, `dbal_local_date`, `dbal_local_datetime`, `dbal_local_time` | `Nextras\Dbal\Utils\DateTimeImmutable` | +| `timestamp`, `unixtimestamp`, `dbal_timestamp` | `Nextras\Dbal\Utils\DateTimeImmutable` | diff --git a/docs/result.md b/docs/result.md index b3babab6..6aeb656d 100644 --- a/docs/result.md +++ b/docs/result.md @@ -59,3 +59,15 @@ $result->unbuffered(); // disable the emulated buffering ``` If the unbuffered Result was already partially consumed, enabling buffering does nothing and Result will potentially throw an exception when rewinded or seeked. If the buffered Result was already partially consumed, disabling buffering does nothing and Result will still use the buffer. + +### Value Normalization + +Dbal automatically normalizes selected column values to PHP types based on driver metadata. You can disable or re-enable that behavior per result: + +```php +$result = $connection->query('SELECT * FROM events'); +$result->setValueNormalization(false); +$result->setValueNormalization(true); +``` + +See the [Result Normalization](result-normalization) chapter for the exact behavior of each driver. diff --git a/readme.md b/readme.md index 61a96361..6585c2af 100644 --- a/readme.md +++ b/readme.md @@ -13,6 +13,7 @@ Supported platforms: - **MySQL** via `mysqli` or `pdo_mysql` extension, - **PostgreSQL** via `pgsql` or `pdo_pgsql` extension, - **MS SQL Server** via `sqlsrv` or `pdo_sqlsrv` extension. +- **Sqlite** via `pdo_sqlite` extension. Integrations: - Symfony Bundle diff --git a/src/Drivers/PdoSqlite/PdoSqliteDriver.php b/src/Drivers/PdoSqlite/PdoSqliteDriver.php new file mode 100644 index 00000000..eb3deec2 --- /dev/null +++ b/src/Drivers/PdoSqlite/PdoSqliteDriver.php @@ -0,0 +1,121 @@ +connectPdo($dsn, '', '', [], $logger); + $this->resultNormalizerFactory = new PdoSqliteResultNormalizerFactory($this); + + $this->connectionTz = new DateTimeZone('UTC'); + $this->loggedQuery('PRAGMA foreign_keys = 1'); + } + + + public function createPlatform(IConnection $connection): IPlatform + { + return new SqlitePlatform($connection); + } + + + public function getLastInsertedId(string|Fqn|null $sequenceName = null): mixed + { + return $this->query('SELECT last_insert_rowid()')->fetchField(); + } + + + public function setTransactionIsolationLevel(int $level): void + { + if ($level === Connection::TRANSACTION_READ_UNCOMMITTED) { + $this->loggedQuery('PRAGMA read_uncommitted = 1'); + } elseif ( + $level === Connection::TRANSACTION_READ_COMMITTED + || $level === Connection::TRANSACTION_REPEATABLE_READ + || $level === Connection::TRANSACTION_SERIALIZABLE + ) { + $this->loggedQuery('PRAGMA read_uncommitted = 0'); + } else { + throw new NotSupportedException("Unsupported transaction level $level"); + } + } + + + protected function createResultAdapter(PDOStatement $statement): IResultAdapter + { + return (new PdoSqliteResultAdapter($statement, $this->resultNormalizerFactory))->toBuffered(); + } + + + protected function convertIdentifierToSql(string|Fqn $identifier): string + { + $escaped = match (true) { + $identifier instanceof Fqn => str_replace(']', ']]', $identifier->schema) . '.' + . str_replace(']', ']]', $identifier->name), + default => str_replace(']', ']]', $identifier), + }; + return '[' . $escaped . ']'; + } + + + protected function createException(string $error, int $errorNo, string $sqlState, ?string $query = null): Exception + { + if (stripos($error, 'FOREIGN KEY constraint failed') !== false) { + return new ForeignKeyConstraintViolationException($error, $errorNo, '', null, $query); + } elseif ( + strpos($error, 'must be unique') !== false + || strpos($error, 'is not unique') !== false + || strpos($error, 'are not unique') !== false + || strpos($error, 'UNIQUE constraint failed') !== false + ) { + return new UniqueConstraintViolationException($error, $errorNo, '', null, $query); + } elseif ( + strpos($error, 'may not be NULL') !== false + || strpos($error, 'NOT NULL constraint failed') !== false + ) { + return new NotNullConstraintViolationException($error, $errorNo, '', null, $query); + } elseif (stripos($error, 'unable to open database') !== false) { + return new ConnectionException($error, $errorNo, ''); + } elseif ($query !== null) { + return new QueryException($error, $errorNo, '', null, $query); + } else { + return new DriverException($error, $errorNo, ''); + } + } +} diff --git a/src/Drivers/PdoSqlite/PdoSqliteResultAdapter.php b/src/Drivers/PdoSqlite/PdoSqliteResultAdapter.php new file mode 100644 index 00000000..cfc9722f --- /dev/null +++ b/src/Drivers/PdoSqlite/PdoSqliteResultAdapter.php @@ -0,0 +1,104 @@ + */ + private $statement; + + /** @var bool */ + private $beforeFirstFetch = true; + + /** @var PdoSqliteResultNormalizerFactory */ + private $normalizerFactory; + + + /** + * @param PDOStatement $statement + */ + public function __construct(PDOStatement $statement, PdoSqliteResultNormalizerFactory $normalizerFactory) + { + $this->statement = $statement; + $this->normalizerFactory = $normalizerFactory; + } + + + public function toBuffered(): IResultAdapter + { + return new FullyBufferedResultAdapter($this); + } + + + public function toUnbuffered(): IResultAdapter + { + return $this; + } + + + public function seek(int $index): void + { + if ($index === 0 && $this->beforeFirstFetch) { + return; + } + + throw new NotSupportedException("PDO does not support rewinding or seeking. Use Result::buffered() before first consume of the result."); + } + + + public function fetch(): ?array + { + $this->beforeFirstFetch = false; + $fetched = $this->statement->fetch(PDO::FETCH_ASSOC); + return $fetched !== false ? $fetched : null; + } + + + public function getRowsCount(): int + { + return $this->statement->rowCount(); + } + + + public function getTypes(): array + { + $types = []; + $count = $this->statement->columnCount(); + + for ($i = 0; $i < $count; $i++) { + $field = $this->statement->getColumnMeta($i); + if ($field === false) { // @phpstan-ignore-line + // Sqlite does not return meta for special queries (PRAGMA, etc.) + continue; + } + + // SQLite exposes sqlite:decl_type at runtime, but PHPStan's built-in PDO shape does not know that key. + /** @phpstan-ignore-next-line */ + $type = strtolower((string) ($field['sqlite:decl_type'] ?? $field['native_type'] ?? '')); + $type = explode('(', $type)[0]; + $types[$field['name']] = $type; + } + + return $types; + } + + + public function getNormalizers(): array + { + return $this->normalizerFactory->resolve($this->getTypes()); + } +} diff --git a/src/Drivers/PdoSqlite/PdoSqliteResultNormalizerFactory.php b/src/Drivers/PdoSqlite/PdoSqliteResultNormalizerFactory.php new file mode 100644 index 00000000..c0492541 --- /dev/null +++ b/src/Drivers/PdoSqlite/PdoSqliteResultNormalizerFactory.php @@ -0,0 +1,159 @@ +intNormalizer = static function ($value): ?int { + if ($value === null) return null; + return (int) $value; + }; + + $this->floatNormalizer = static function ($value): ?float { + if ($value === null) return null; + return (float) $value; + }; + + $this->boolNormalizer = static function ($value): ?bool { + if ($value === null) return null; + return (bool) $value; + }; + + $this->dateTimeNormalizer = static function ($value) use ($driver, $applicationTimeZone): ?DateTimeImmutable { + if ($value === null) return null; + + if (is_int($value) || (is_string($value) && ctype_digit($value))) { + $milliseconds = (int) $value; + $seconds = intdiv($milliseconds, 1000); + $microseconds = ($milliseconds % 1000) * 1000; + $dateTime = DateTimeImmutable::createFromFormat( + 'U.u', + sprintf('%d.%06d', $seconds, $microseconds), + new DateTimeZone('UTC'), + ); + assert($dateTime !== false); + return $dateTime->setTimezone($applicationTimeZone); + } + + $dateTime = new DateTimeImmutable($value . ' ' . $driver->getConnectionTimeZone()->getName()); + return $dateTime->setTimezone($applicationTimeZone); + }; + + $this->localDateTimeNormalizer = static function ($value) use ($applicationTimeZone): ?DateTimeImmutable { + if ($value === null) return null; + $dateTime = new DateTimeImmutable((string) $value); + return $dateTime->setTimezone($applicationTimeZone); + }; + } + + + /** + * @param array $types + * @return array + */ + public function resolve(array $types): array + { + static $ints = [ + 'int' => true, + 'integer' => true, + 'tinyint' => true, + 'smallint' => true, + 'mediumint' => true, + 'bigint' => true, + 'unsigned big int' => true, + 'int2' => true, + 'int8' => true, + ]; + + static $floats = [ + 'real' => true, + 'double' => true, + 'double precision' => true, + 'float' => true, + 'numeric' => true, + 'decimal' => true, + ]; + + static $bools = [ + 'bool' => true, + 'boolean' => true, + 'bit' => true, + 'dbal_bool' => true, + ]; + + static $localDateTimes = [ + 'date' => true, + 'datetime' => true, + 'time' => true, + 'localdate' => true, + 'localdatetime' => true, + 'localtime' => true, + 'dbal_local_date' => true, + 'dbal_local_datetime' => true, + 'dbal_local_time' => true, + ]; + + static $dateTimes = [ + 'timestamp' => true, + 'unixtimestamp' => true, + 'dbal_timestamp' => true, + ]; + + $normalizers = []; + foreach ($types as $column => $type) { + if ($type === 'text' || $type === 'varchar') { + continue; // optimization + } elseif (isset($ints[$type])) { + $normalizers[$column] = $this->intNormalizer; + } elseif (isset($floats[$type])) { + $normalizers[$column] = $this->floatNormalizer; + } elseif (isset($bools[$type])) { + $normalizers[$column] = $this->boolNormalizer; + } elseif (isset($localDateTimes[$type])) { + $normalizers[$column] = $this->localDateTimeNormalizer; + } elseif (isset($dateTimes[$type])) { + $normalizers[$column] = $this->dateTimeNormalizer; + } + } + return $normalizers; + } +} diff --git a/src/Platforms/IPlatform.php b/src/Platforms/IPlatform.php index c13a7a71..d2ec2fd3 100644 --- a/src/Platforms/IPlatform.php +++ b/src/Platforms/IPlatform.php @@ -16,6 +16,7 @@ interface IPlatform public const SUPPORT_MULTI_COLUMN_IN = 1; public const SUPPORT_QUERY_EXPLAIN = 2; public const SUPPORT_WHITESPACE_EXPLAIN = 3; + public const SUPPORT_INSERT_DEFAULT_KEYWORD = 4; /** diff --git a/src/Platforms/MySqlPlatform.php b/src/Platforms/MySqlPlatform.php index 06b27539..9f8eb6b9 100644 --- a/src/Platforms/MySqlPlatform.php +++ b/src/Platforms/MySqlPlatform.php @@ -242,6 +242,7 @@ public function isSupported(int $feature): bool static $supported = [ self::SUPPORT_MULTI_COLUMN_IN => true, self::SUPPORT_QUERY_EXPLAIN => true, + self::SUPPORT_INSERT_DEFAULT_KEYWORD => true, ]; return isset($supported[$feature]); } diff --git a/src/Platforms/PostgreSqlPlatform.php b/src/Platforms/PostgreSqlPlatform.php index 3603c4fd..be873243 100644 --- a/src/Platforms/PostgreSqlPlatform.php +++ b/src/Platforms/PostgreSqlPlatform.php @@ -290,6 +290,7 @@ public function isSupported(int $feature): bool self::SUPPORT_MULTI_COLUMN_IN => true, self::SUPPORT_QUERY_EXPLAIN => true, self::SUPPORT_WHITESPACE_EXPLAIN => true, + self::SUPPORT_INSERT_DEFAULT_KEYWORD => true, ]; return isset($supported[$feature]); } diff --git a/src/Platforms/SqlServerPlatform.php b/src/Platforms/SqlServerPlatform.php index a8a7f348..a19faf04 100644 --- a/src/Platforms/SqlServerPlatform.php +++ b/src/Platforms/SqlServerPlatform.php @@ -264,6 +264,7 @@ public function createMultiQueryParser(): IMultiQueryParser public function isSupported(int $feature): bool { static $supported = [ + self::SUPPORT_INSERT_DEFAULT_KEYWORD => true, ]; return isset($supported[$feature]); } diff --git a/src/Platforms/SqlitePlatform.php b/src/Platforms/SqlitePlatform.php new file mode 100644 index 00000000..b640418b --- /dev/null +++ b/src/Platforms/SqlitePlatform.php @@ -0,0 +1,234 @@ +connection = $connection; + $this->driver = $connection->getDriver(); + } + + + public function getName(): string + { + return self::NAME; + } + + + public function getTables(?string $schema = null): array + { + $result = $this->connection->query(/** @lang SQLite */ " + SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' + UNION ALL + SELECT name, type FROM sqlite_temp_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' + "); + + $tables = []; + foreach ($result as $row) { + $table = new Table( + fqnName: new Fqn('', (string) $row->name), + isView: $row->type === 'view', + ); + $tables[$table->fqnName->getUnescaped()] = $table; + } + return $tables; + } + + + public function getColumns(string $table, ?string $schema = null): array + { + $raw = $this->connection->query(/** @lang SQLite */ " + SELECT sql FROM sqlite_master WHERE type = 'table' AND name = %s + UNION ALL + SELECT sql FROM sqlite_temp_master WHERE type = 'table' AND name = %s + ", $table, $table)->fetchField(); + + $result = $this->connection->query(/** @lang SQLite */ " + PRAGMA table_info(%table) + ", $table); + + $columns = []; + foreach ($result as $row) { + $column = $row->name; + $pattern = "~(\"$column\"|`$column`|\\[$column\\]|$column)\\s+[^,]+\\s+PRIMARY\\s+KEY\\s+AUTOINCREMENT~Ui"; + + $type = explode('(', $row->type); + $column = new Column( + name: (string) $row->name, + type: $type[0], + size: isset($type[1]) ? (int) $type[1] : 0, + default: $row->dflt_value !== null ? (string) $row->dflt_value : null, + isPrimary: $row->pk === 1, + isAutoincrement: preg_match($pattern, (string) $raw) === 1, + isUnsigned: false, + isNullable: $row->notnull === 0, + meta: [], + ); + $columns[$column->name] = $column; + } + return $columns; + } + + + public function getForeignKeys(string $table, ?string $schema = null): array + { + $result = $this->connection->query(/** @lang SQLite */ " + PRAGMA foreign_key_list(%table) + ", $table); + + $foreignKeys = []; + foreach ($result as $row) { + $foreignKey = new ForeignKey( + fqnName: new Fqn('', (string) $row->id), + column: (string) $row->from, + refTable: new Fqn('', (string) $row->table), + refColumn: (string) $row->to, + ); + $foreignKeys[$foreignKey->column] = $foreignKey; + } + return $foreignKeys; + } + + + public function getPrimarySequenceName(string $table, ?string $schema = null): ?string + { + return null; + } + + + public function formatString(string $value): string + { + return $this->driver->convertStringToSql($value); + } + + + public function formatStringLike(string $value, int $mode) + { + $value = strtr($value, [ + "'" => "''", + '!' => '!!', + '%' => '!%', + '_' => '!_', + ]); + return ($mode <= 0 ? "'%" : "'") . $value . ($mode >= 0 ? "%'" : "'") . " ESCAPE '!'"; + } + + + public function formatJson(mixed $value): string + { + $encoded = JsonHelper::safeEncode($value); + return $this->formatString($encoded); + } + + + public function formatBool(bool $value): string + { + return $value ? '1' : '0'; + } + + + public function formatIdentifier(string $value): string + { + return '[' . str_replace([']', '.'], [']]', '].['], $value) . ']'; + } + + + public function formatDateTime(DateTimeInterface $value): string + { + $value = DateTimeHelper::convertToTimezone($value, $this->driver->getConnectionTimeZone()); + return strval(((int) $value->format('U')) * 1000 + intdiv((int) $value->format('u'), 1000)); + } + + + public function formatLocalDateTime(DateTimeInterface $value): string + { + return "'" . $value->format('Y-m-d H:i:s.u') . "'"; + } + + + public function formatLocalDate(DateTimeInterface $value): string + { + return "'" . $value->format('Y-m-d') . "'"; + } + + + public function formatDateInterval(DateInterval $value): string + { + throw new NotSupportedException(); + } + + + public function formatBlob(string $value): string + { + return "X'" . bin2hex($value) . "'"; + } + + + public function formatLimitOffset(?int $limit, ?int $offset): string + { + if ($limit === null && $offset === null) { + return ''; + } elseif ($limit === null) { + return 'LIMIT -1 OFFSET ' . $offset; + } elseif ($offset === null) { + return "LIMIT $limit"; + } else { + return "LIMIT $limit OFFSET $offset"; + } + } + + + public function createMultiQueryParser(): IMultiQueryParser + { + if (!class_exists(SqliteMultiQueryParser::class)) { + throw new \RuntimeException("Missing nextras/multi-query-parser dependency. Install it first to use IPlatform::createMultiQueryParser()."); + } + return new SqliteMultiQueryParser(); + } + + + public function isSupported(int $feature): bool + { + static $supported = [ + self::SUPPORT_QUERY_EXPLAIN => true, + ]; + return isset($supported[$feature]); + } +} diff --git a/src/Result/FullyBufferedResultAdapter.php b/src/Result/FullyBufferedResultAdapter.php new file mode 100644 index 00000000..6af38225 --- /dev/null +++ b/src/Result/FullyBufferedResultAdapter.php @@ -0,0 +1,43 @@ +|null */ + protected $types = null; + + + public function getTypes(): array + { + $this->getData(); + assert($this->types !== null); + return $this->types; + } + + + public function getRowsCount(): int + { + return $this->getData()->count(); + } + + + protected function fetchData(): ArrayIterator + { + $rows = []; + while (($row = $this->adapter->fetch()) !== null) { + if ($this->types === null) { + $this->types = $this->adapter->getTypes(); + } + $rows[] = $row; + } + return new ArrayIterator($rows); + } +} diff --git a/src/SqlProcessor.php b/src/SqlProcessor.php index 2e13d9db..8b7612d9 100644 --- a/src/SqlProcessor.php +++ b/src/SqlProcessor.php @@ -528,7 +528,9 @@ protected function processMultiValues(array $value): string private function processValues(array $value): string { if (count($value) === 0) { - return 'VALUES (DEFAULT)'; + return $this->platform->isSupported(IPlatform::SUPPORT_INSERT_DEFAULT_KEYWORD) + ? 'VALUES (DEFAULT)' + : 'DEFAULT VALUES'; } $keys = $values = []; diff --git a/tests/cases/integration/connection.sqlite.phpt b/tests/cases/integration/connection.sqlite.phpt new file mode 100644 index 00000000..0d542ae4 --- /dev/null +++ b/tests/cases/integration/connection.sqlite.phpt @@ -0,0 +1,54 @@ +connection->query('CREATE TEMP TABLE %table (id INT PRIMARY KEY)', $tableName); + Assert::same( + 1, + $this->connection->query( + "SELECT COUNT(*) FROM sqlite_temp_master WHERE type = 'table' AND name = %s", + $tableName + )->fetchField() + ); + + $this->connection->reconnect(); + + Assert::same( + 0, + $this->connection->query( + "SELECT COUNT(*) FROM sqlite_temp_master WHERE type = 'table' AND name = %s", + $tableName + )->fetchField() + ); + } + + + public function testLastInsertId() + { + $this->initData($this->connection); + + $this->connection->query('INSERT INTO publishers %values', ['name' => 'FOO']); + Assert::same(2, $this->connection->getLastInsertedId()); + } +} + + +$test = new ConnectionSqliteTest(); +$test->run(); diff --git a/tests/cases/integration/datetime.sqlite.phpt b/tests/cases/integration/datetime.sqlite.phpt new file mode 100644 index 00000000..44bf1c4b --- /dev/null +++ b/tests/cases/integration/datetime.sqlite.phpt @@ -0,0 +1,120 @@ +createConnection(); + $this->lockConnection($connection); + + $connection->query(/** @lang GenericSQL */ ' + CREATE TEMP TABLE dates_write ( + a varchar, + b numeric + ); + '); + + $connection->query('INSERT INTO dates_write VALUES (%ldt, %dt)', + new DateTime('2015-01-01 12:00:00'), // local + new DateTime('2015-01-01 12:00:00') // 11:00 UTC + ); + + $result = $connection->query('SELECT * FROM dates_write'); + $result->setValueNormalization(false); + + $row = $result->fetch(); + Assert::same('2015-01-01 12:00:00.000000', $row->a); + Assert::same(strtotime('2015-01-01T11:00:00Z') * 1000, $row->b * 1); + + $connection->query('DELETE FROM dates_write'); + $connection->query('INSERT INTO dates_write VALUES (%ldt, %dt)', + new DateTime('2015-01-01 12:00:00'), // local + new DateTime('2015-01-01 12:00:00 Europe/Kiev') // 10:00 UTC, + ); + + $result = $connection->query('SELECT * FROM dates_write'); + $result->setValueNormalization(false); + + $row = $result->fetch(); + Assert::same('2015-01-01 12:00:00.000000', $row->a); + Assert::same(strtotime('2015-01-01T10:00:00Z') * 1000, $row->b * 1); + } + + + public function testReadStorage() + { + $connection = $this->createConnection(); + $this->lockConnection($connection); + + $connection->query('DROP TABLE IF EXISTS dates_read'); + $connection->query(' + CREATE TABLE dates_read ( + a dbal_local_datetime, + b dbal_timestamp, + c dbal_local_date + ); + '); + + $connection->query('INSERT INTO dates_read VALUES (%s, %s, %s)', + '2015-01-01 12:00:00', // local + '2015-01-01 12:00:00', // connection tz + '2015-01-01' + ); + + $result = $connection->query('SELECT * FROM dates_read'); + + $row = $result->fetch(); + Assert::type(DateTimeImmutable::class, $row->a); + Assert::type(DateTimeImmutable::class, $row->b); + Assert::type(DateTimeImmutable::class, $row->c); + Assert::same('2015-01-01T12:00:00+01:00', $row->a->format('c')); + Assert::same('2015-01-01T13:00:00+01:00', $row->b->format('c')); + Assert::same('2015-01-01T00:00:00+01:00', $row->c->format('c')); + } + + + public function testMicroseconds() + { + $connection = $this->createConnection(); + $this->lockConnection($connection); + + $connection->query('DROP TABLE IF EXISTS dates_micro'); + $connection->query(' + CREATE TABLE dates_micro ( + a dbal_local_datetime(6), + b dbal_timestamp(6) + ); + '); + + $now = new DateTime(); + $connection->query('INSERT INTO dates_micro %values', [ + 'a%ldt' => $now, + 'b%dt' => $now, + ]); + + $row = $connection->query('SELECT * FROM dates_micro')->fetch(); + Assert::same($now->format('u'), $row->a->format('u')); + Assert::same(substr($now->format('u'), 0, 3) . '000', $row->b->format('u')); + } +} + + +$test = new DateTimeSqliteTest(); +$test->run(); diff --git a/tests/cases/integration/exceptions.phpt b/tests/cases/integration/exceptions.phpt index f547d246..b8ae7554 100644 --- a/tests/cases/integration/exceptions.phpt +++ b/tests/cases/integration/exceptions.phpt @@ -7,12 +7,16 @@ namespace NextrasTests\Dbal; + use Nextras\Dbal\Drivers\Exception\ConnectionException; use Nextras\Dbal\Drivers\Exception\ForeignKeyConstraintViolationException; use Nextras\Dbal\Drivers\Exception\NotNullConstraintViolationException; use Nextras\Dbal\Drivers\Exception\QueryException; use Nextras\Dbal\Drivers\Exception\UniqueConstraintViolationException; +use Nextras\Dbal\Drivers\PdoSqlite\PdoSqliteDriver; use Tester\Assert; +use Tester\Environment; + require_once __DIR__ . '/../../bootstrap.php'; @@ -22,6 +26,10 @@ class ExceptionsTest extends IntegrationTestCase public function testConnection() { + if ($this->connection->getDriver() instanceof PdoSqliteDriver) { + Environment::skip('Connection cannot fail because wrong configuration.'); + } + Assert::exception(function () { $connection = $this->createConnection(['database' => 'unknown']); $connection->connect(); diff --git a/tests/cases/integration/platform.format.sqlite.phpt b/tests/cases/integration/platform.format.sqlite.phpt new file mode 100644 index 00000000..90a4f9bd --- /dev/null +++ b/tests/cases/integration/platform.format.sqlite.phpt @@ -0,0 +1,68 @@ +connection->getPlatform(); + $this->connection->connect(); + + Assert::same('[foo]', $platform->formatIdentifier('foo')); + Assert::same('[foo].[bar]', $platform->formatIdentifier('foo.bar')); + Assert::same('[foo].[bar].[baz]', $platform->formatIdentifier('foo.bar.baz')); + } + + + public function testDateInterval() + { + Assert::exception(function () { + $interval1 = (new DateTime('2015-01-03 12:01:01'))->diff(new DateTime('2015-01-01 09:00:00')); + $this->connection->getPlatform()->formatDateInterval($interval1); + }, NotSupportedException::class); + } + + + public function testLike() + { + $c = $this->connection; + Assert::falsey($c->query("SELECT 'AAxBB' LIKE %_like_", "A'B")->fetchField()); + Assert::truthy($c->query("SELECT 'AA''BB' LIKE %_like_", "A'B")->fetchField()); + + Assert::falsey($c->query("SELECT 'AAxBB' LIKE %_like_", "A\\B")->fetchField()); + Assert::truthy($c->query("SELECT 'AA\\BB' LIKE %_like_", "A\\B")->fetchField()); + + Assert::falsey($c->query("SELECT 'AAxBB' LIKE %_like_", "A%B")->fetchField()); + Assert::truthy($c->query("SELECT %raw LIKE %_like_", "'AA%BB'", "A%B")->fetchField()); + + Assert::falsey($c->query("SELECT 'AAxBB' LIKE %_like_", "A_B")->fetchField()); + Assert::truthy($c->query("SELECT 'AA_BB' LIKE %_like_", "A_B")->fetchField()); + + Assert::falsey($c->query("SELECT 'AAxBB' LIKE %_like", "AAAxBB")->fetchField()); + Assert::falsey($c->query("SELECT 'AAxBB' LIKE %_like", "AxB")->fetchField()); + Assert::truthy($c->query("SELECT 'AAxBB' LIKE %_like", "AxBB")->fetchField()); + + Assert::falsey($c->query("SELECT 'AAxBB' LIKE %like_", "AAxBBB")->fetchField()); + Assert::falsey($c->query("SELECT 'AAxBB' LIKE %like_", "AxB")->fetchField()); + Assert::truthy($c->query("SELECT 'AAxBB' LIKE %like_", "AAxB")->fetchField()); + } +} + + +$test = new PlatformFormatSqliteTest(); +$test->run(); diff --git a/tests/cases/integration/platform.sqlite.phpt b/tests/cases/integration/platform.sqlite.phpt new file mode 100644 index 00000000..3c1d2bab --- /dev/null +++ b/tests/cases/integration/platform.sqlite.phpt @@ -0,0 +1,219 @@ +lockConnection($this->connection); + $tables = $this->connection->getPlatform()->getTables(); + + Assert::true(isset($tables["books"])); + Assert::same('books', $tables["books"]->fqnName->name); + Assert::same(false, $tables["books"]->isView); + + Assert::true(isset($tables["my_books"])); + Assert::same('my_books', $tables["my_books"]->fqnName->name); + Assert::same(true, $tables["my_books"]->isView); + } + + + public function testColumns() + { + $this->lockConnection($this->connection); + $columns = $this->connection->getPlatform()->getColumns('books'); + $columns = array_map(function ($column) { + return (array) $column; + }, $columns); + + Assert::same([ + 'id' => [ + 'name' => 'id', + 'type' => 'INTEGER', + 'size' => 0, + 'default' => null, + 'isPrimary' => true, + 'isAutoincrement' => true, + 'isUnsigned' => false, + 'isNullable' => false, + 'meta' => [], + ], + 'author_id' => [ + 'name' => 'author_id', + 'type' => 'INT', + 'size' => 0, + 'default' => null, + 'isPrimary' => false, + 'isAutoincrement' => false, + 'isUnsigned' => false, + 'isNullable' => false, + 'meta' => [], + ], + 'translator_id' => [ + 'name' => 'translator_id', + 'type' => 'INT', + 'size' => 0, + 'default' => null, + 'isPrimary' => false, + 'isAutoincrement' => false, + 'isUnsigned' => false, + 'isNullable' => true, + 'meta' => [], + ], + 'title' => [ + 'name' => 'title', + 'type' => 'varchar', + 'size' => 50, + 'default' => null, + 'isPrimary' => false, + 'isAutoincrement' => false, + 'isUnsigned' => false, + 'isNullable' => false, + 'meta' => [], + ], + 'publisher_id' => [ + 'name' => 'publisher_id', + 'type' => 'INT', + 'size' => 0, + 'default' => null, + 'isPrimary' => false, + 'isAutoincrement' => false, + 'isUnsigned' => false, + 'isNullable' => false, + 'meta' => [], + ], + 'ean_id' => [ + 'name' => 'ean_id', + 'type' => 'INT', + 'size' => 0, + 'default' => null, + 'isPrimary' => false, + 'isAutoincrement' => false, + 'isUnsigned' => false, + 'isNullable' => true, + 'meta' => [], + ], + ], $columns); + + $schemaColumns = $this->connection->getPlatform()->getColumns('authors'); + $schemaColumns = array_map(function ($column) { + return (array) $column; + }, $schemaColumns); + + Assert::same([ + 'id' => [ + 'name' => 'id', + 'type' => 'INTEGER', + 'size' => 0, + 'default' => null, + 'isPrimary' => true, + 'isAutoincrement' => true, + 'isUnsigned' => false, + 'isNullable' => false, + 'meta' => [], + ], + 'name' => [ + 'name' => 'name', + 'type' => 'varchar', + 'size' => 50, + 'default' => null, + 'isPrimary' => false, + 'isAutoincrement' => false, + 'isUnsigned' => false, + 'isNullable' => false, + 'meta' => [], + ], + 'web' => [ + 'name' => 'web', + 'type' => 'varchar', + 'size' => 100, + 'default' => null, + 'isPrimary' => false, + 'isAutoincrement' => false, + 'isUnsigned' => false, + 'isNullable' => false, + 'meta' => [], + ], + 'born' => [ + 'name' => 'born', + 'type' => 'date', + 'size' => 0, + 'default' => 'NULL', + 'isPrimary' => false, + 'isAutoincrement' => false, + 'isUnsigned' => false, + 'isNullable' => true, + 'meta' => [], + ], + ], $schemaColumns); + } + + + public function testForeignKeys() + { + $this->lockConnection($this->connection); + + $keys = $this->connection->getPlatform()->getForeignKeys('books'); + $keys = array_map(function ($key) { + return (array) $key; + }, $keys); + + Assert::equal([ + 'ean_id' => [ + 'fqnName' => new Fqn('', '0'), + 'column' => 'ean_id', + 'refTable' => new Fqn('', 'eans'), + 'refColumn' => 'id', + ], + 'publisher_id' => [ + 'fqnName' => new Fqn('', '1'), + 'column' => 'publisher_id', + 'refTable' => new Fqn('', 'publishers'), + 'refColumn' => 'id', + ], + 'translator_id' => [ + 'fqnName' => new Fqn('', '2'), + 'column' => 'translator_id', + 'refTable' => new Fqn('', 'authors'), + 'refColumn' => 'id', + ], + 'author_id' => [ + 'fqnName' => new Fqn('', '3'), + 'column' => 'author_id', + 'refTable' => new Fqn('', 'authors'), + 'refColumn' => 'id', + ], + ], $keys); + } + + + public function testPrimarySequence() + { + Assert::same(null, $this->connection->getPlatform()->getPrimarySequenceName('books')); + } + + + public function testName() + { + Assert::same('sqlite', $this->connection->getPlatform()->getName()); + } +} + + +$test = new PlatformSqliteTest(); +$test->run(); diff --git a/tests/cases/integration/result.phpt b/tests/cases/integration/result.phpt index eaeda9d2..7917b4e7 100644 --- a/tests/cases/integration/result.phpt +++ b/tests/cases/integration/result.phpt @@ -10,6 +10,7 @@ namespace NextrasTests\Dbal; use Nextras\Dbal\Drivers\PdoMysql\PdoMysqlDriver; use Nextras\Dbal\Drivers\PdoPgsql\PdoPgsqlDriver; +use Nextras\Dbal\Drivers\PdoSqlite\PdoSqliteDriver; use Nextras\Dbal\Drivers\Sqlsrv\SqlsrvDriver; use Nextras\Dbal\Exception\InvalidArgumentException; use Nextras\Dbal\Utils\DateTimeImmutable; @@ -50,6 +51,7 @@ class ResultIntegrationTest extends IntegrationTestCase if ( $this->connection->getDriver() instanceof SqlsrvDriver || $this->connection->getDriver() instanceof PdoPgsqlDriver + || $this->connection->getDriver() instanceof PdoSqliteDriver || $this->connection->getDriver() instanceof PdoMysqlDriver && version_compare(PHP_VERSION, '8.1.0-RC1') >= 0 ) { Assert::same(2, $follower->tag_id); diff --git a/tests/cases/integration/sqlPreprocessor.phpt b/tests/cases/integration/sqlPreprocessor.phpt index f96ea199..f83bc344 100644 --- a/tests/cases/integration/sqlPreprocessor.phpt +++ b/tests/cases/integration/sqlPreprocessor.phpt @@ -10,6 +10,7 @@ namespace NextrasTests\Dbal; use Nextras\Dbal\ISqlProcessorFactory; use Nextras\Dbal\Platforms\PostgreSqlPlatform; +use Nextras\Dbal\Platforms\SqlitePlatform; use Nextras\Dbal\Result\Row; use Tester\Assert; @@ -24,8 +25,17 @@ class SqlPreprocessorIntegrationTest extends IntegrationTestCase $this->lockConnection($this->connection); $this->connection->query('DELETE FROM table_with_defaults'); $this->connection->query('INSERT INTO table_with_defaults %values', []); - $this->connection->query('INSERT INTO table_with_defaults %values[]', [[]]); - $this->connection->query('INSERT INTO table_with_defaults %values[]', [[], []]); + + if ($this->connection->getPlatform()->getName() === SqlitePlatform::NAME) { + // SQLite supports only single-row "DEFAULT VALUES" inserts. + $this->connection->query('INSERT INTO table_with_defaults %values', []); + $this->connection->query('INSERT INTO table_with_defaults %values', []); + $this->connection->query('INSERT INTO table_with_defaults %values', []); + } else { + $this->connection->query('INSERT INTO table_with_defaults %values[]', [[]]); + $this->connection->query('INSERT INTO table_with_defaults %values[]', [[], []]); + } + $count = $this->connection->query('SELECT COUNT(*) FROM table_with_defaults')->fetchField(); Assert::equal(4, $count); } diff --git a/tests/cases/integration/types.sqlite.phpt b/tests/cases/integration/types.sqlite.phpt new file mode 100644 index 00000000..50128c85 --- /dev/null +++ b/tests/cases/integration/types.sqlite.phpt @@ -0,0 +1,97 @@ +connection->query(" + CREATE TEMP TABLE types_read ( + local_date dbal_local_date, + local_datetime dbal_local_datetime, + utc_datetime_ms dbal_timestamp, + local_time dbal_local_time, + integer1 tinyint, + integer2 smallint, + integer3 int, + integer4 bigint, + float1 float, + real1 real, + numeric1 numeric(5,2), + numeric2 numeric(5,2), + numeric3 numeric, + decimal1 decimal(5,2), + decimal2 decimal(5,2), + decimal3 decimal, + boolean dbal_bool + ); + "); + $this->connection->query('INSERT INTO types_read %values', [ + 'local_date' => '2017-02-22', + 'local_datetime' => '2017-02-22 16:40:00', + 'utc_datetime_ms' => strtotime('2017-02-22T16:40:00Z') * 1000, + 'local_time' => '16:40', + 'integer1' => 1, + 'integer2' => 1, + 'integer3' => 1, + 'integer4' => 1, + 'float1' => 12, + 'real1' => 12, + 'numeric1' => 12.04, + 'numeric2' => 12, + 'numeric3' => 12, + 'decimal1' => 12.04, + 'decimal2' => 12, + 'decimal3' => 12, + 'boolean' => 1, + ]); + + $result = $this->connection->query('SELECT * FROM types_read'); + + $row = $result->fetch(); + Assert::type(DateTimeImmutable::class, $row->local_date); + Assert::type(DateTimeImmutable::class, $row->local_datetime); + Assert::type(DateTimeImmutable::class, $row->utc_datetime_ms); + Assert::type(DateTimeImmutable::class, $row->local_time); + Assert::same('2017-02-22T00:00:00+01:00', $row->local_date->format('c')); + Assert::same('2017-02-22T16:40:00+01:00', $row->local_datetime->format('c')); + Assert::same('2017-02-22T17:40:00+01:00', $row->utc_datetime_ms->format('c')); + Assert::same('16:40:00', $row->local_time->format('H:i:s')); + + Assert::same(1, $row->integer1); + Assert::same(1, $row->integer2); + Assert::same(1, $row->integer3); + Assert::same(1, $row->integer4); + + Assert::same(12.0, $row->float1); + Assert::same(12.0, $row->real1); + + Assert::same(12.04, $row->numeric1); + Assert::same(12.00, $row->numeric2); + Assert::same(12.0, $row->numeric3); + + Assert::same(12.04, $row->decimal1); + Assert::same(12.00, $row->decimal2); + Assert::same(12.0, $row->decimal3); + + Assert::same(true, $row->boolean); + } +} + + +$test = new TypesSqliteTest(); +$test->run(); diff --git a/tests/cases/unit/SqlProcessorTest.values.phpt b/tests/cases/unit/SqlProcessorTest.values.phpt index 60a07eca..afbea393 100644 --- a/tests/cases/unit/SqlProcessorTest.values.phpt +++ b/tests/cases/unit/SqlProcessorTest.values.phpt @@ -79,8 +79,12 @@ class SqlProcessorValuesTest extends TestCase public function testInsertWithDefaults() { + $this->platform->shouldReceive('isSupported') + ->with(IPlatform::SUPPORT_INSERT_DEFAULT_KEYWORD) + ->once() + ->andReturn(false); Assert::same( - "INSERT INTO test VALUES (DEFAULT)", + "INSERT INTO test DEFAULT VALUES", $this->convert('INSERT INTO test %values', []) ); diff --git a/tests/data/sqlite-data.sql b/tests/data/sqlite-data.sql new file mode 100644 index 00000000..c1dc97c7 --- /dev/null +++ b/tests/data/sqlite-data.sql @@ -0,0 +1,32 @@ +-- SET FOREIGN_KEY_CHECKS = 0; +DELETE FROM books_x_tags; +DELETE FROM books; +DELETE FROM tags; +DELETE FROM authors; +DELETE FROM publishers; +DELETE FROM tag_followers; +-- SET FOREIGN_KEY_CHECKS = 1; + +INSERT INTO authors (id, name, web, born) VALUES (1, 'Writer 1', 'http://example.com/1', NULL); +INSERT INTO authors (id, name, web, born) VALUES (2, 'Writer 2', 'http://example.com/2', NULL); + +INSERT INTO publishers (id, name) VALUES (1, 'Nextras publisher'); + +INSERT INTO tags (id, name) VALUES (1, 'Tag 1'); +INSERT INTO tags (id, name) VALUES (2, 'Tag 2'); +INSERT INTO tags (id, name) VALUES (3, 'Tag 3'); + +INSERT INTO books (id, author_id, translator_id, title, publisher_id) VALUES (1, 1, 1, 'Book 1', 1); +INSERT INTO books (id, author_id, translator_id, title, publisher_id) VALUES (2, 1, NULL, 'Book 2', 1); +INSERT INTO books (id, author_id, translator_id, title, publisher_id) VALUES (3, 2, 2, 'Book 3', 1); +INSERT INTO books (id, author_id, translator_id, title, publisher_id) VALUES (4, 2, 2, 'Book 4', 1); + +INSERT INTO books_x_tags (book_id, tag_id) VALUES (1, 1); +INSERT INTO books_x_tags (book_id, tag_id) VALUES (1, 2); +INSERT INTO books_x_tags (book_id, tag_id) VALUES (2, 2); +INSERT INTO books_x_tags (book_id, tag_id) VALUES (2, 3); +INSERT INTO books_x_tags (book_id, tag_id) VALUES (3, 3); + +INSERT INTO tag_followers (tag_id, author_id, created_at) VALUES (1, 1, '2014-01-01 00:10:00'); +INSERT INTO tag_followers (tag_id, author_id, created_at) VALUES (3, 1, '2014-01-01 00:10:00'); +INSERT INTO tag_followers (tag_id, author_id, created_at) VALUES (2, 2, '2014-01-01 00:10:00'); diff --git a/tests/data/sqlite-init.sql b/tests/data/sqlite-init.sql new file mode 100644 index 00000000..57ae3e8b --- /dev/null +++ b/tests/data/sqlite-init.sql @@ -0,0 +1,62 @@ +CREATE TABLE authors ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name varchar(50) NOT NULL, + web varchar(100) NOT NULL, + born date DEFAULT NULL +); + + +CREATE TABLE publishers ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name varchar(50) NOT NULL +); + +CREATE UNIQUE INDEX publishes_name ON publishers (name); + +CREATE TABLE tags ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name varchar(50) NOT NULL +); + +CREATE TABLE eans ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + code varchar(50) NOT NULL +); + +CREATE TABLE books ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + author_id int NOT NULL, + translator_id int, + title varchar(50) NOT NULL, + publisher_id int NOT NULL, + ean_id int, + CONSTRAINT books_authors FOREIGN KEY (author_id) REFERENCES authors (id), + CONSTRAINT books_translator FOREIGN KEY (translator_id) REFERENCES authors (id), + CONSTRAINT books_publisher FOREIGN KEY (publisher_id) REFERENCES publishers (id), + CONSTRAINT books_ean FOREIGN KEY (ean_id) REFERENCES eans (id) +); + +CREATE INDEX book_title ON books (title); + +CREATE VIEW my_books AS SELECT * FROM books WHERE author_id = 1; + +CREATE TABLE books_x_tags ( + book_id int NOT NULL, + tag_id int NOT NULL, + PRIMARY KEY (book_id, tag_id), + CONSTRAINT books_x_tags_tag FOREIGN KEY (tag_id) REFERENCES tags (id), + CONSTRAINT books_x_tags_book FOREIGN KEY (book_id) REFERENCES books (id) ON DELETE CASCADE +); + +CREATE TABLE tag_followers ( + tag_id int NOT NULL, + author_id int NOT NULL, + created_at datetime NOT NULL, + PRIMARY KEY (tag_id, author_id), + CONSTRAINT tag_followers_tag FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT tag_followers_author FOREIGN KEY (author_id) REFERENCES authors (id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE table_with_defaults ( + name VARCHAR(255) DEFAULT 'Jon Snow' +); diff --git a/tests/data/sqlite-reset.php b/tests/data/sqlite-reset.php new file mode 100644 index 00000000..d43914a1 --- /dev/null +++ b/tests/data/sqlite-reset.php @@ -0,0 +1,11 @@ +disconnect(); + @unlink($config['filename']); + $connection->connect(); +}; diff --git a/tests/databases.github.ini b/tests/databases.github.ini index 8ae390ce..e87bab33 100644 --- a/tests/databases.github.ini +++ b/tests/databases.github.ini @@ -111,3 +111,7 @@ password = "YourStrong!Passw0rd" database = nextras_dbal_test port = 1433 TrustServerCertificate = true + +[sqlite] +driver = pdo_sqlite +filename = "sqlite.db" diff --git a/tests/databases.sample.ini b/tests/databases.sample.ini index de32dbeb..57d30e3b 100644 --- a/tests/databases.sample.ini +++ b/tests/databases.sample.ini @@ -21,3 +21,7 @@ database = nextras_dbal_test username = postgres password = postgres port = 5432 + +[sqlite] +driver = pdo_sqlite +filename = "sqlite.db" diff --git a/tests/inc/IntegrationTestCase.php b/tests/inc/IntegrationTestCase.php index 3051a987..6b8a6a60 100644 --- a/tests/inc/IntegrationTestCase.php +++ b/tests/inc/IntegrationTestCase.php @@ -44,6 +44,11 @@ protected function createConnection($params = []) 'searchPath' => ['public'], 'sqlProcessorFactory' => new SqlProcessorFactory(), ], Environment::loadData(), $params); + + if (isset($options['filename']) && $options['filename'] !== ':memory:') { + $options['filename'] = __DIR__ . '/../temp/' . $options['filename']; + } + return new Connection($options); } diff --git a/tests/inc/setup.php b/tests/inc/setup.php index d6d75e7e..a3953c17 100644 --- a/tests/inc/setup.php +++ b/tests/inc/setup.php @@ -27,6 +27,10 @@ $processed[$key] = true; echo "[setup] Bootstrapping '{$name}' structure.\n"; + if (isset($configDatabase['filename']) && $configDatabase['filename'] !== ':memory:') { + $configDatabase['filename'] = __DIR__ . '/../temp/' . $configDatabase['filename']; + } + $connection = new Connection($configDatabase); $platform = $connection->getPlatform(); $platformName = $platform->getName();