diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 8096c64..8f8cd08 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -34,6 +34,7 @@ jobs: --health-interval=10s --health-timeout=20s --health-retries=10 + postgres: image: postgres env: @@ -44,6 +45,7 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 + sqlserver: image: mcr.microsoft.com/mssql/server env: diff --git a/.gitpod.yml b/.gitpod.yml index 635b3d3..55fba05 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -1,10 +1,13 @@ tasks: - name: Run Composer - command: | - composer install + init: | sudo ln -s /etc/php/8.2/mods-available/sqlsrv.ini /etc/php/8.2/cli/conf.d/20-sqlsrv.ini sudo ln -s /etc/php/8.2/mods-available/pdo_sqlsrv.ini /etc/php/8.2/cli/conf.d/30-pdo_sqlsrv.ini sudo ln -s /etc/php/8.2/mods-available/oci8.ini /etc/php/8.2/cli/conf.d/20-oci8.ini + echo oci8.privileged_connect=1 | sudo tee -a /etc/php/8.2/mods-available/oci8.ini + + command: | + composer install docker compose -f docker-compose.yml up -d image: byjg/gitpod-image:full diff --git a/README.md b/README.md index c81f3bf..932bc2c 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,6 @@ See below the current implemented drivers: | Postgres | psql://username:password@hostname:port/database | getDbRelationalInstance() | | Sql Server (DbLib) | dblib://username:password@hostname:port/database | getDbRelationalInstance() | | Sql Server (Sqlsrv) | sqlsrv://username:password@hostname:port/database | getDbRelationalInstance() | -| Oracle (OCI) | oci://username:password@hostname:port/database | getDbRelationalInstance() | | Oracle (OCI8) | oci8://username:password@hostname:port/database | getDbRelationalInstance() | | Generic PDO | pdo://username:password@pdo_driver?PDO_PARAMETERS | getDbRelationalInstance() | @@ -42,19 +41,23 @@ $conn = \ByJG\AnyDataset\Db\Factory::getDbRelationalInstance("mysql://root:passw ## Examples -- [Basic Query and Update](basic-query.md) -- [Cache results](cache.md) -- [Database Transaction](transaction.md) -- [Load Balance and Connection Pooling](load-balance.md) -- [Database Helper](helper.md) +- [Basic Query and Update](docs/basic-query.md) +- [Cache results](docs/cache.md) +- [Database Transaction](docs/transaction.md) +- [Load Balance and Connection Pooling](docs/load-balance.md) +- [Database Helper](docs/helper.md) ## Advanced Topics -- [Passing Parameters to PDODriver](parameters.md) -- [MySQL SSL Connection](mysql-ssl.md) -- [FreeTDS/Dblib Date Issue](freetds.md) -- [Generic PDO Driver](generic-pdo-driver.md) -- [Running Tests](tests.md) +- [Passing Parameters to PDODriver](docs/parameters.md) +- [Generic PDO Driver](docs/generic-pdo-driver.md) +- [Running Tests](docs/tests.md) + +## Database Specifics + +- [MySQL](docs/mysql.md) +- [Oracle](docs/oracle.md) +- [SQLServer](docs/sqlserver.md) ## Install diff --git a/docker-compose.yml b/docker-compose.yml index 99642c1..a2ed622 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,3 +41,19 @@ services: timeout: 20s interval: 10s retries: 10 + + oracle: + container_name: oracle + image: container-registry.oracle.com/database/express:21.3.0-xe + environment: + - ORACLE_PWD=password + ports: + - "1521:1521" + volumes: + - ./testsdb/assets:/opt/oracle/scripts/startup + +# healthcheck: +# test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ] +# timeout: 20s +# interval: 10s +# retries: 10 diff --git a/docs/freetds.md b/docs/freetds.md deleted file mode 100644 index 599ab65..0000000 --- a/docs/freetds.md +++ /dev/null @@ -1,6 +0,0 @@ -# FreeDTS / Dblib Date format Issues - -Date has the format `"Jul 27 2016 22:00:00.860"`. The solution is: - -Follow the solution: -[https://stackoverflow.com/questions/38615458/freetds-dateformat-issues](https://stackoverflow.com/questions/38615458/freetds-dateformat-issues) diff --git a/docs/mysql-ssl.md b/docs/mysql.md similarity index 84% rename from docs/mysql-ssl.md rename to docs/mysql.md index c1373ef..09699d5 100644 --- a/docs/mysql-ssl.md +++ b/docs/mysql.md @@ -1,4 +1,8 @@ -# Connecting To MySQL via SSL +# Driver: MySQL + +The connection string can have special attributes to connect using SSL. + +## Connecting To MySQL via SSL Read [here](https://gist.github.com/byjg/860065a828150caf29c20209ecbd5692) about create SSL mysql diff --git a/docs/oracle.md b/docs/oracle.md new file mode 100644 index 0000000..a2e5e1b --- /dev/null +++ b/docs/oracle.md @@ -0,0 +1,27 @@ +# Driver: Oracle + +The Oracle Driver don't use the PHP PDO Driver. Instead, uses the OCI library. + +The Oracle Connection String has the following format: + + +```text + oci8://user:pass@server:port/serviceName?parameters +``` + +The `parameters` can be: + +* `codepage`=UTF8 +* `conntype`=default|persistent|new +* `session_mode`=OCI_DEFAULT|OCI_SYSDBA|OCI_SYSOPER + +## conntype + +* If conntype = default will call the `oci_connect()` command; +* If conntype = new will call the `oci_new_connect()` command; +* If conntype = persistent will call the `oci_pconnect()` command; + +## session_mode + +The `OCI_DEFAULT`, `OCI_SYSDBA` AND `OCI_SYSOPER` are the PHP Constants +and they are `0`, `2` and `4` respectively; \ No newline at end of file diff --git a/docs/sqlserver.md b/docs/sqlserver.md new file mode 100644 index 0000000..f38ddaf --- /dev/null +++ b/docs/sqlserver.md @@ -0,0 +1,14 @@ +# Driver: Microsoft SQL Server + +The SQLServer can be connected using both FreeDTS / Dblib (Sybase) or SQLSVR driver. + +They have specifics, but both are able to connect to SQLServer. + +There are some specifics as you can see below. + +## The Date format Issues + +Date has the format `"Jul 27 2016 22:00:00.860"`. The solution is: + +Follow the solution: +[https://stackoverflow.com/questions/38615458/freetds-dateformat-issues](https://stackoverflow.com/questions/38615458/freetds-dateformat-issues) diff --git a/src/DbOci8Driver.php b/src/DbOci8Driver.php new file mode 100644 index 0000000..0ca8b3a --- /dev/null +++ b/src/DbOci8Driver.php @@ -0,0 +1,392 @@ +logger = new NullLogger(); + $this->connectionUri = $connectionString; + $this->ociAutoCommit = OCI_COMMIT_ON_SUCCESS; + $this->reconnect(); + } + + /** + * + * @param Uri $connUri + * @return string + */ + public static function getTnsString(Uri $connUri) + { + $protocol = $connUri->getQueryPart("protocol"); + $protocol = ($protocol == "") ? 'TCP' : $protocol; + + $port = $connUri->getPort(); + $port = ($port == "") ? 1521 : $port; + + $svcName = preg_replace('~^/~', '', $connUri->getPath()); + + $host = $connUri->getHost(); + + return "(DESCRIPTION = " . + " (ADDRESS = (PROTOCOL = $protocol)(HOST = $host)(PORT = $port)) " . + " (CONNECT_DATA = ". + " (SERVICE_NAME = $svcName) " . + " ) " . + ")"; + } + + public function __destruct() + { + $this->conn = null; + } + + /** + * @param $sql + * @param null $array + * @return resource + * @throws DatabaseException + */ + protected function getOci8Cursor($sql, $array = null) + { + if (is_null($this->conn)) { + throw new DbDriverNotConnected('Instance not connected'); + } + list($query, $array) = SqlBind::parseSQL($this->connectionUri, $sql, $array); + + $this->logger->debug("SQL: $query, Params: " . json_encode($array)); + + // Prepare the statement + $query = rtrim($query, ' ;'); + $stid = oci_parse($this->conn, $query); + if (!$stid) { + $error = oci_error($this->conn); + throw new DatabaseException($error['message']); + } + + // Bind the parameters + if (is_array($array)) { + foreach ($array as $key => $value) { + oci_bind_by_name($stid, ":$key", $array[$key], -1); + } + } + + // Perform the logic of the query + $result = oci_execute($stid, $this->ociAutoCommit); + + // Check if is OK; + if (!$result) { + $error = oci_error($stid); + throw new DatabaseException($error['message']); + } + + return $stid; + } + + /** + * @param $sql + * @param null $params + * @return Oci8Iterator + * @throws DatabaseException + */ + public function getIterator($sql, $params = null, CacheInterface $cache = null, $ttl = 60) + { + return $this->getIteratorUsingCache($sql, $params, $cache, $ttl, function ($sql, $params) { + $cur = $this->getOci8Cursor($sql, $params); + return new Oci8Iterator($cur); + }); + } + + /** + * @param $sql + * @param null $array + * @return null + * @throws DatabaseException + */ + public function getScalar($sql, $array = null) + { + $cur = $this->getOci8Cursor($sql, $array); + + $row = oci_fetch_array($cur, OCI_RETURN_NULLS); + if ($row) { + $scalar = $row[0]; + } else { + $scalar = null; + } + + oci_free_cursor($cur); + + return $scalar; + } + + /** + * @param $tablename + * @return array + * @throws DatabaseException + */ + public function getAllFields($tablename) + { + $cur = $this->getOci8Cursor(SqlHelper::createSafeSQL("select * from :table", array(':table' => $tablename))); + + $ncols = oci_num_fields($cur); + + $fields = array(); + for ($i = 1; $i <= $ncols; $i++) { + $fields[] = strtolower(oci_field_name($cur, $i)); + } + + oci_free_statement($cur); + + return $fields; + } + + protected function transactionHandler($action, $isolLevelCommand = "") + { + switch ($action) { + case 'begin': + $this->ociAutoCommit = OCI_NO_AUTO_COMMIT; + $this->execute($isolLevelCommand); + break; + + case 'commit': + if ($this->ociAutoCommit == OCI_COMMIT_ON_SUCCESS) { + throw new DatabaseException('No transaction for commit'); + } + + $this->ociAutoCommit = OCI_COMMIT_ON_SUCCESS; + + $result = oci_commit($this->conn); + if (!$result) { + $error = oci_error($this->conn); + throw new DatabaseException($error['message']); + } + break; + + case 'rollback': + if ($this->ociAutoCommit == OCI_COMMIT_ON_SUCCESS) { + throw new DatabaseException('No transaction for rollback'); + } + + $this->ociAutoCommit = OCI_COMMIT_ON_SUCCESS; + + oci_rollback($this->conn); + break; + } + } + + /** + * @param $sql + * @param null $array + * @return bool + * @throws DatabaseException + */ + public function execute($sql, $array = null) + { + $cur = $this->getOci8Cursor($sql, $array); + oci_free_cursor($cur); + return true; + } + + /** + * + * @return resource + */ + public function getDbConnection() + { + return $this->conn; + } + + /** + * @param $name + * @throws NotImplementedException + */ + public function getAttribute($name) + { + throw new NotImplementedException('Method not implemented for OCI Driver'); + } + + /** + * @param $name + * @param $value + * @throws NotImplementedException + */ + public function setAttribute($name, $value) + { + throw new NotImplementedException('Method not implemented for OCI Driver'); + } + + /** + * @param $sql + * @param null $array + * @throws NotImplementedException + */ + public function executeAndGetId($sql, $array = null) + { + return $this->getDbHelper()->executeAndGetInsertedId($this, $sql, $array); + } + + /** + * @return \ByJG\AnyDataset\Db\DbFunctionsInterface|void + * @throws NotImplementedException + */ + public function getDbHelper() + { + if (empty($this->dbHelper)) { + $this->dbHelper = Factory::getDbFunctions($this->getUri()); + } + return $this->dbHelper; + } + + /** + * @return Uri + */ + public function getUri() + { + return $this->connectionUri; + } + + /** + * @throws NotImplementedException + */ + public function isSupportMultRowset() + { + return false; + } + + /** + * @param $multipleRowSet + * @throws NotImplementedException + */ + public function setSupportMultRowset($multipleRowSet) + { + throw new NotImplementedException('Method not implemented for OCI Driver'); + } + + public function reconnect($force = false) + { + if ($this->isConnected() && !$force) { + return false; + } + + // Release old instance + $this->disconnect(); + + // Connect + $codePage = $this->connectionUri->getQueryPart("codepage"); + $codePage = ($codePage == "") ? 'UTF8' : $codePage; + $tns = DbOci8Driver::getTnsString($this->connectionUri); + $connType = $this->connectionUri->getQueryPart("conntype"); + switch ($connType) { + case "persistent": + $connectMethod = "oci_pconnect"; + break; + case "new": + $connectMethod = "oci_new_connect"; + break; + default: + $connectMethod = "oci_connect"; + break; + } + + $this->conn = $connectMethod( + $this->connectionUri->getUsername(), + $this->connectionUri->getPassword(), + $tns, + $codePage, + $this->connectionUri->getQueryPart('session_mode') ?? OCI_DEFAULT + ); + + if (!$this->conn) { + $error = oci_error(); + throw new DatabaseException($error['message']); + } + + return true; + } + + public function disconnect() + { + $this->clearCache(); + $this->conn = null; + } + + public function isConnected($softCheck = false, $throwError = false) + { + if (empty($this->conn)) { + if ($throwError) { + throw new DbDriverNotConnected('DbDriver not connected'); + } + return false; + } + + if ($softCheck) { + return true; + } + + try { + oci_parse($this->conn, "SELECT 1 FROM DUAL"); // Do not use $this->getInstance() + } catch (Exception $ex) { + if ($throwError) { + throw new DbDriverNotConnected('DbDriver not connected'); + } + return false; + } + + return true; + } + + public function enableLogger(LoggerInterface $logger) + { + $this->logger = $logger; + } + + public function log($message, $context = []) + { + $this->logger->debug($message, $context); + } +} \ No newline at end of file diff --git a/src/DbPdoDriver.php b/src/DbPdoDriver.php index fd9586c..187efec 100644 --- a/src/DbPdoDriver.php +++ b/src/DbPdoDriver.php @@ -8,7 +8,6 @@ use ByJG\AnyDataset\Db\Helpers\SqlHelper; use ByJG\AnyDataset\Db\Traits\DbCacheTrait; use ByJG\AnyDataset\Db\Traits\TransactionTrait; -use ByJG\AnyDataset\Lists\ArrayDataset; use ByJG\Util\Uri; use Exception; use PDO; @@ -128,29 +127,11 @@ protected function getDBStatement($sql, $array = null) public function getIterator($sql, $params = null, CacheInterface $cache = null, $ttl = 60) { - if (!empty($cache)) { - // Otherwise try to get from cache - $key = $this->getQueryKey($sql, $params); - - // Get the CACHE - $cachedItem = $cache->get($key); - if (!is_null($cachedItem)) { - return (new ArrayDataset($cachedItem))->getIterator(); - } - } - - - $stmt = $this->getDBStatement($sql, $params); - $stmt->execute(); - $iterator = new DbIterator($stmt); - - if (!empty($cache)) { - $cachedItem = $iterator->toArray(); - $cache->set($key, $cachedItem, $ttl); - return (new ArrayDataset($cachedItem))->getIterator(); - } - - return $iterator; + return $this->getIteratorUsingCache($sql, $params, $cache, $ttl, function ($sql, $params) { + $stmt = $this->getDBStatement($sql, $params); + $stmt->execute(); + return new DbIterator($stmt); + }); } public function getScalar($sql, $array = null) @@ -298,25 +279,21 @@ public function log($message, $context = []) $this->logger->debug($message, $context); } - protected function array_map_assoc($callback, $array) + protected function transactionHandler($action, $isolLevelCommand = "") { - $r = array(); - foreach ($array as $key=>$value) { - $r[$key] = $callback($key, $value); + switch ($action) { + case 'begin': + if (!empty($isolLevelCommand)) { + $this->getInstance()->exec($isolLevelCommand); + } + $this->getInstance()->beginTransaction(); + break; + case 'commit': + $this->getInstance()->commit(); + break; + case 'rollback': + $this->getInstance()->rollBack(); + break; } - return $r; - } - - protected function getQueryKey($sql, $array) - { - $key1 = md5($sql); - $key2 = ""; - - // Check which parameter exists in the SQL - if (is_array($array)) { - $key2 = md5(":" . implode(',', $this->array_map_assoc(function($k,$v){return "$k:$v";},$array))); - } - - return "qry:" . $key1 . $key2; } } diff --git a/src/Factory.php b/src/Factory.php index c73c971..8483179 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -59,6 +59,7 @@ public static function getDbInstance($connectionUri) self::registerDbDriver(PdoOdbc::class); self::registerDbDriver(PdoPdo::class); self::registerDbDriver(PdoOci::class); + self::registerDbDriver(DbOci8Driver::class); } $scheme = $connectionUri->getScheme(); diff --git a/src/Helpers/DbBaseFunctions.php b/src/Helpers/DbBaseFunctions.php index c9b9492..e083786 100644 --- a/src/Helpers/DbBaseFunctions.php +++ b/src/Helpers/DbBaseFunctions.php @@ -199,15 +199,12 @@ protected function getTableMetadataFromSql(DbDriverInterface $dbdataset, $sql) return $this->parseColumnMetadata($metadata); } - protected function parseColumnMetadata($metadata) - { - throw new Exception("Not implemented"); - } + abstract protected function parseColumnMetadata($metadata); protected function parseTypeMetadata($type) { $matches = []; - if (!preg_match('/(?[a-z\s]+)(\((?\d+)(,(?\d+))?\))?/i', $type, $matches)) { + if (!preg_match('/(?[a-z0-9\s]+)(\((?\d+)(,(?\d+))?\))?/i', $type, $matches)) { return [ 'phpType' => 'string', 'length' => null, 'precision' => null ]; } diff --git a/src/Helpers/DbOci8Functions.php b/src/Helpers/DbOci8Functions.php new file mode 100644 index 0000000..1258aa7 --- /dev/null +++ b/src/Helpers/DbOci8Functions.php @@ -0,0 +1,217 @@ +deliFieldLeft = '"'; + $this->deliFieldRight = '"'; + $this->deliTableLeft = '"'; + $this->deliTableRight = '"'; + } + + public function concat($str1, $str2 = null) + { + return implode(' || ', func_get_args()); + } + + /** + * Given a SQL returns it with the proper LIMIT or equivalent method included + * @param string $sql + * @param int $start + * @param int $qty + * @return string + */ + public function limit($sql, $start, $qty = null) + { + if (is_null($qty)) { + $qty = 50; + } + + if (stripos($sql, ' OFFSET ') === false && stripos($sql, ' FETCH NEXT ') === false) { + $sql = $sql . " OFFSET x ROWS FETCH NEXT y ROWS ONLY"; + } + + return preg_replace( + '~(\s[Oo][Ff][Ff][Ss][Ee][Tt])\s.*?\s([Rr][Oo][Ww][Ss])\s.*?\s([Ff][Ee][Tt][Cc][Hh]\s[Nn][Ee][Xx][Tt])\s.*~', + '$1 ' . $start . ' $2 ' . '$3 ' . $qty . ' ROWS ONLY', + $sql + ); + } + + /** + * Given a SQL returns it with the proper TOP or equivalent method included + * @param string $sql + * @param int $qty + * @return string + */ + public function top($sql, $qty) + { + return $this->limit($sql, 0, $qty); + } + + /** + * Return if the database provider have a top or similar function + * @return bool + */ + public function hasTop() + { + return true; + } + + /** + * Return if the database provider have a limit function + * @return bool + */ + public function hasLimit() + { + return true; + } + + /** + * Format date column in sql string given an input format that understands Y M D + * + * @param string $format + * @param string|null $column + * @return string + * @example $db->getDbFunctions()->SQLDate("d/m/Y H:i", "dtcriacao") + */ + public function sqlDate($format, $column = null) + { + if (is_null($column)) { + $column = 'current_timestamp'; + } + + $pattern = [ + 'Y' => "YYYY", + 'y' => "YY", + 'M' => "Mon", + 'm' => "MM", + 'Q' => "Q", + 'q' => "Q", + 'D' => "DD", + 'd' => "DD", + 'h' => "HH", + 'H' => "HH24", + 'i' => "MI", + 's' => "SS", + 'a' => "AM", + 'A' => "AM", + ]; + + return sprintf( + "TO_CHAR(%s,'%s')", + $column, + implode('', $this->prepareSqlDate($format, $pattern, '')) + ); + } + + /** + * @param DbDriverInterface $dbdataset + * @param string $sql + * @param array $param + * @return int + */ + public function executeAndGetInsertedId(DbDriverInterface $dbdataset, $sql, $param) + { + preg_match('/INSERT INTO ([a-zA-Z0-9_]+)/i', $sql, $matches); + $tableName = $matches[1] ?? null; + + if (!empty($tableName)) { + $tableName = strtoupper($tableName); + + // Get the primary key of the table + $primaryKeyResult = $dbdataset->getScalar("SELECT cols.column_name + FROM all_constraints cons, all_cons_columns cols + WHERE cols.table_name = '{$tableName}' + AND cons.constraint_type = 'P' + AND cons.constraint_name = cols.constraint_name + AND cons.owner = cols.owner + AND ROWNUM = 1"); + + // Get the default value of the primary key + $defaultValueResult = $dbdataset->getScalar("SELECT DATA_DEFAULT + FROM USER_TAB_COLUMNS + WHERE TABLE_NAME = '{$tableName}' + AND COLUMN_NAME = '{$primaryKeyResult}'"); + } + + $dbdataset->execute($sql, $param); + + if (!empty($tableName) && !empty($defaultValueResult)) { + + // Check if the default value is a sequence's nextval + if (strpos($defaultValueResult, '.nextval') !== false) { + // Extract the sequence name + $sequenceName = str_replace('.nextval', '', $defaultValueResult); + + // Return the CURRVAL of the sequence + return $dbdataset->getScalar("SELECT {$sequenceName}.currval FROM DUAL"); + } + } + + return null; + + } + + public function hasForUpdate() + { + return true; + } + + public function getTableMetadata(DbDriverInterface $dbdataset, $tableName) + { + $tableName = strtoupper($tableName); + $sql = "SELECT + COLUMN_NAME, + DATA_TYPE || + CASE + WHEN COALESCE(DATA_PRECISION, CHAR_LENGTH, 0) <> 0 + THEN '(' || COALESCE(DATA_PRECISION, CHAR_LENGTH) || ( + CASE WHEN COALESCE(DATA_SCALE, 0) <> 0 THEN ',' || DATA_SCALE END + ) || ')' + END AS TYPE, + DATA_DEFAULT AS COLUMN_DEFAULT, + NULLABLE + FROM ALL_TAB_COLUMNS WHERE TABLE_NAME = '{$tableName}'"; + + return $this->getTableMetadataFromSql($dbdataset, $sql); + } + + protected function parseColumnMetadata($metadata) + { + $return = []; + + foreach ($metadata as $key => $value) { + $return[strtolower($value['column_name'])] = [ + 'name' => $value['column_name'], + 'dbType' => strtolower($value['type']), + 'required' => $value['nullable'] == 'N', + 'default' => isset($value['column_default']) ? $value['column_default'] : null, + ] + $this->parseTypeMetadata(strtolower($value['type'])); + } + + return $return; + } + + public function getIsolationLevelCommand($isolationLevel) + { + switch ($isolationLevel) { + case IsolationLevelEnum::READ_UNCOMMITTED: + return "SET TRANSACTION READ WRITE"; + case IsolationLevelEnum::READ_COMMITTED: + case IsolationLevelEnum::REPEATABLE_READ: + return "SET TRANSACTION ISOLATION LEVEL READ COMMITTED"; + case IsolationLevelEnum::SERIALIZABLE: + return "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE"; + default: + return ""; + } + } +} diff --git a/src/Oci8Iterator.php b/src/Oci8Iterator.php new file mode 100644 index 0000000..6c076e8 --- /dev/null +++ b/src/Oci8Iterator.php @@ -0,0 +1,107 @@ +cursor = $cursor; + $this->rowBuffer = array(); + } + + /** + * @access public + * @return int + */ + public function count() + { + return -1; + } + + /** + * @access public + * @return bool + * @throws InvalidArgumentException + */ + public function hasNext() + { + if (count($this->rowBuffer) >= Oci8Iterator::RECORD_BUFFER) { + return true; + } + + if (is_null($this->cursor)) { + return (count($this->rowBuffer) > 0); + } + + $rowArray = oci_fetch_assoc($this->cursor); + if (!empty($rowArray)) { + $rowArray = array_change_key_case($rowArray, CASE_LOWER); + $singleRow = new Row($rowArray); + + $this->currentRow++; + + // Enfileira o registo + $this->rowBuffer[] = $singleRow; + // Traz novos até encher o Buffer + if (count($this->rowBuffer) < DbIterator::RECORD_BUFFER) { + $this->hasNext(); + } + return true; + } + + oci_free_statement($this->cursor); + $this->cursor = null; + return (count($this->rowBuffer) > 0); + } + + public function __destruct() + { + if (!is_null($this->cursor)) { + oci_free_statement($this->cursor); + $this->cursor = null; + } + } + + /** + * @return mixed + * @throws IteratorException + * @throws InvalidArgumentException + */ + public function moveNext() + { + if (!$this->hasNext()) { + throw new IteratorException("No more records. Did you used hasNext() before moveNext()?"); + } else { + $row = array_shift($this->rowBuffer); + $this->moveNextRow++; + return $row; + } + } + + public function key() + { + return $this->moveNextRow; + } +} diff --git a/src/PdoOci.php b/src/PdoOci.php index 854b7d8..7b4daf4 100644 --- a/src/PdoOci.php +++ b/src/PdoOci.php @@ -9,7 +9,7 @@ class PdoOci extends PdoLiteral public static function schema() { - return ['oci']; + return ['oracle']; } public function __construct(Uri $connUri) @@ -32,8 +32,7 @@ public static function getTnsString(Uri $connUri) $protocol = $connUri->getQueryPart("protocol"); $protocol = ($protocol == "") ? 'TCP' : $protocol; - $port = $connUri->getPort(); - $port = ($port == "") ? 1521 : $port; + $port = $connUri->getPort() ?? 1521; $svcName = preg_replace('~^/~', '', $connUri->getPath()); diff --git a/src/Traits/DbCacheTrait.php b/src/Traits/DbCacheTrait.php index ce042ce..7742e26 100644 --- a/src/Traits/DbCacheTrait.php +++ b/src/Traits/DbCacheTrait.php @@ -2,6 +2,10 @@ namespace ByJG\AnyDataset\Db\Traits; +use ByJG\AnyDataset\Core\GenericIterator; +use ByJG\AnyDataset\Lists\ArrayDataset; +use Psr\SimpleCache\CacheInterface; + trait DbCacheTrait { protected $stmtCache = []; @@ -45,4 +49,104 @@ protected function getOrSetSqlCacheStmt($sql) return $this->stmtCache[$sql]; } + + public function getIteratorUsingCache($sql, $params, ?CacheInterface $cache, $ttl, \Closure $closure): GenericIterator + { + $cacheKey = $this->getQueryKey($cache, $sql, $params); + + do { + $lock = $this->mutexIsLocked($cache, $cacheKey); + if ($lock !== false) { + usleep(1000); + continue; + } + + $cachedResult = $this->getCachedResult($cacheKey, $cache); + if (!empty($cachedResult)) { + return $cachedResult; + } + $this->mutexLock($cache, $cacheKey); + try { + $iterator = $closure($sql, $params); + return $this->cacheResult($cacheKey, $iterator, $cache, $ttl); + } finally { + $this->mutexRelease($cache, $cacheKey); + } + } while (true); + } + + protected function mutexIsLocked(?CacheInterface $cache, ?string $cacheKey) + { + if (empty($cache)) { + return false; + } + return $cache->get($cacheKey . ".lock", false); + } + + protected function mutexLock(?CacheInterface $cache, ?string $cacheKey) + { + if (empty($cache)) { + return; + } + $cache->set($cacheKey . ".lock", time(), \DateInterval::createFromDateString('5 min'));; + } + + protected function mutexRelease(?CacheInterface $cache, ?string $cacheKey) + { + if (empty($cache)) { + return; + } + $cache->delete($cacheKey . ".lock"); + } + + protected function getCachedResult($key, ?CacheInterface $cache): ?GenericIterator + { + if (!empty($cache)) { + // Get the CACHE + $cachedItem = $cache->get($key); + if (!is_null($cachedItem)) { + return (new ArrayDataset($cachedItem))->getIterator(); + } + } + + return null; + } + + protected function cacheResult($key, GenericIterator $iterator, ?CacheInterface $cache, $ttl): GenericIterator + { + if (!empty($cache)) { + $cachedItem = $iterator->toArray(); + $cache->set($key, $cachedItem, $ttl); + return (new ArrayDataset($cachedItem))->getIterator(); + } + + return $iterator; + } + + protected function array_map_assoc($callback, $array) + { + $r = array(); + foreach ($array as $key=>$value) { + $r[$key] = $callback($key, $value); + } + return $r; + } + + protected function getQueryKey(?CacheInterface $cache, $sql, $array) + { + if (empty($cache)) { + return null; + } + + $key1 = md5($sql); + $key2 = ""; + + // Check which parameter exists in the SQL + if (is_array($array)) { + $key2 = md5(":" . implode(',', $this->array_map_assoc(function($k,$v){return "$k:$v";},$array))); + } + + return "qry:" . $key1 . $key2; + } + } \ No newline at end of file diff --git a/src/Traits/TransactionTrait.php b/src/Traits/TransactionTrait.php index b9e4670..fab1b30 100644 --- a/src/Traits/TransactionTrait.php +++ b/src/Traits/TransactionTrait.php @@ -30,10 +30,7 @@ public function beginTransaction($isolationLevel = null, $allowJoin = false) $this->logger->debug("SQL: Begin transaction"); $isolLevelCommand = $this->getDbHelper()->getIsolationLevelCommand($isolationLevel); - if (!empty($isolLevelCommand)) { - $this->getInstance()->exec($isolLevelCommand); - } - $this->getInstance()->beginTransaction(); + $this->transactionHandler('begin', $isolLevelCommand); $this->transactionCount = 1; $this->isolationLevel = $isolationLevel; } @@ -48,7 +45,7 @@ public function commitTransaction() if ($this->transactionCount > 0) { return; } - $this->getInstance()->commit(); + $this->transactionHandler('commit'); $this->isolationLevel = null; } @@ -58,7 +55,7 @@ public function rollbackTransaction() if (!$this->hasActiveTransaction()) { throw new TransactionNotStartedException("There is no active transaction"); } - $this->getInstance()->rollBack(); + $this->transactionHandler('rollback'); $this->transactionCount = 0; $this->isolationLevel = null; } diff --git a/tests/PdoSqliteTest.php b/tests/PdoSqliteTest.php index c23b7f7..fee8304 100644 --- a/tests/PdoSqliteTest.php +++ b/tests/PdoSqliteTest.php @@ -295,7 +295,7 @@ public function testCachedResults() ); } - public function testCachedResultsNotFound() + public function testCachedResults1() { $cache = new ArrayCacheEngine(); @@ -305,18 +305,31 @@ public function testCachedResultsNotFound() [], $iterator->toArray() ); - $iterator = $this->dbDriver->getIterator('select * from info where id = :id', ['id' => 1], $cache, 60); + + // Add a new record to DB + $id = $this->dbDriver->execute("insert into info (iduser, number, property) values (2, 20, 40)"); + $this->assertEquals(4, $id); + $iterator = $this->dbDriver->getIterator('select * from info where id = :id', ['id' => 4]); $this->assertEquals( [ - [ "__id" => 0, "__key" => 0, 'id'=> 1, 'iduser' => 1, 'number' => 10.45, 'property' => 'xxx'], + ["id" => 4, "iduser" => 2, "number" => 20, "property" => '40'], ], $iterator->toArray() ); - // Remove it from DB (Still in cache) - $this->dbDriver->execute("insert into users (name, createdate) values ('John Doe 2', '2018-01-02')"); + // Get from cache, should return the same values as before the insert + $iterator = $this->dbDriver->getIterator('select * from info where id = :id', ['id' => 4], $cache, 60); + $this->assertEquals( + [], + $iterator->toArray() + ); + } - // Try get from cache + public function testCachedResults2() + { + $cache = new ArrayCacheEngine(); + + // Try get from cache (still return the same values) $iterator = $this->dbDriver->getIterator('select * from info where id = :id', ['id' => 1], $cache, 60); $this->assertEquals( [ @@ -324,9 +337,23 @@ public function testCachedResultsNotFound() ], $iterator->toArray() ); - $iterator = $this->dbDriver->getIterator('select * from info where id = :id', ['id' => 4], $cache, 60); + + // Update a record to DB + $id = $this->dbDriver->execute("update info set number = 1500 where id = :id", ["id" => 1]); + $iterator = $this->dbDriver->getIterator('select * from info where id = :id', ['id' => 1]); $this->assertEquals( - [], + [ + ["id" => 1, "iduser" => 1, "number" => 1500, "property" => 'xxx'], + ], + $iterator->toArray() + ); + + // Get from cache, should return the same values as before the update + $iterator = $this->dbDriver->getIterator('select * from info where id = :id', ['id' => 1], $cache, 60); + $this->assertEquals( + [ + [ "__id" => 0, "__key" => 0, 'id'=> 1, 'iduser' => 1, 'number' => 10.45, 'property' => 'xxx'], + ], $iterator->toArray() ); } diff --git a/testsdb/BasePdo.php b/testsdb/BasePdo.php index f82edc3..43e73c6 100644 --- a/testsdb/BasePdo.php +++ b/testsdb/BasePdo.php @@ -384,7 +384,12 @@ public function testGetMetadata() unset($metadata[$key]['dbType']); } - $this->assertEquals([ + $this->assertEquals($this->getExpectedMetadata(), $metadata); + } + + protected function getExpectedMetadata() + { + return [ 'id' => [ 'name' => 'Id', 'required' => true, @@ -425,7 +430,7 @@ public function testGetMetadata() 'length' => $this->floatSize, 'precision' => 2, ], - ], $metadata); + ]; } public function testDisconnect() diff --git a/testsdb/PdoOciTest.php b/testsdb/PdoOciTest.php new file mode 100644 index 0000000..f7283b5 --- /dev/null +++ b/testsdb/PdoOciTest.php @@ -0,0 +1,106 @@ +connType = "default"; + parent::setUp(); + } + + protected function createInstance() + { + $this->escapeQuote = "''"; + + $host = getenv('ORACLE_TEST_HOST'); + if (empty($host)) { + $host = "127.0.0.1"; + } + $password = getenv('ORACLE_PASSWORD'); + if (empty($password)) { + $password = 'password'; + } + if ($password == '.') { + $password = ""; + } + $database = getenv('ORACLE_DATABASE'); + if (empty($database)) { + $database = 'XE'; + } + + return Factory::getDbRelationalInstance("oci8://C##TEST:$password@$host/$database?session_mode=" . OCI_DEFAULT . "&conntype=" . $this->connType); + } + + protected function createDatabase() + { + //create the database + $this->dbDriver->execute("CREATE TABLE Dogs ( + Id INT GENERATED ALWAYS as IDENTITY(START with 1 INCREMENT by 1), + Breed varchar2(50), + Name varchar2(50), + Age INT, + Weight number(10,2), + CONSTRAINT dogs_pk PRIMARY KEY (Id))"); + } + + public function deleteDatabase() + { + $this->dbDriver->execute('drop table Dogs'); + } + + public function testGetDate() { + $data = $this->dbDriver->getScalar("SELECT TO_DATE('2018-07-26', 'YYYY-MM-DD') FROM DUAL "); + $this->assertEquals("26-JUL-18", $data); + + $data = $this->dbDriver->getScalar("SELECT TO_TIMESTAMP('2018-07-26 20:02:03', 'YYYY-MM-DD HH24:MI:SS') FROM DUAL "); + $this->assertEquals("26-JUL-18 08.02.03.000000000 PM", $data); + } + + public function testGetMetadata() + { + $metadata = $this->dbDriver->getDbHelper()->getTableMetadata($this->dbDriver, 'Dogs'); + + foreach ($metadata as $key => $field) { + unset($metadata[$key]['dbType']); + } + $this->assertStringContainsString('.nextval', $metadata['id']['default']); + $metadata['id']['default'] = null; + + $this->assertEquals($this->getExpectedMetadata(), $metadata); + } + + protected function getExpectedMetadata() + { + $expected = parent::getExpectedMetadata(); + + foreach ($expected as $key => $value) { + $expected[$key]["name"] = strtoupper($expected[$key]["name"]); + } + $expected['id']["phpType"] = "float"; + $expected['age']["phpType"] = "float"; + + return $expected; + } + + public function testTwoDifferentTransactions() + { + $this->connType = "new"; + parent::testTwoDifferentTransactions(); + } + +// public function testDontParseParam_3() { +// $this->expectException(\PDOException::class); +// +// parent::testDontParseParam_3(); +// } + +} diff --git a/testsdb/assets/01-users.sql b/testsdb/assets/01-users.sql new file mode 100644 index 0000000..8b766cf --- /dev/null +++ b/testsdb/assets/01-users.sql @@ -0,0 +1,4 @@ + +CREATE USER c##test IDENTIFIED BY password; + +GRANT ALL PRIVILEGES TO C##TEST;