Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Oracle database support #11

Draft
wants to merge 2 commits into
base: feature/oracle
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/workflows/ci-oraclel.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
on:
- pull_request
- push

name: ci-oracle

jobs:
tests:
name: PHP ${{ matrix.php-version }}-oracle-${{ matrix.pgsql-version }}
env:
extensions: curl, intl, pdo, pdo_oci, oci8
key: cache-v1

runs-on: ${{ matrix.os }}

strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
php-version:
- "8.0"
- "8.1"

services:
oci:
image: wnameless/oracle-xe-11g-r2:latest
ports:
- 1521:1521
options: --name=oci

steps:
- name: Checkout
uses: actions/checkout@v2

- name: Setup cache environment
id: cache-env
uses: shivammathur/cache-extensions@v1
with:
php-version: ${{ matrix.php-version }}
extensions: ${{ env.extensions }}
key: ${{ env.key }}

- name: Cache extensions
uses: actions/cache@v2
with:
path: ${{ steps.cache-env.outputs.dir }}
key: ${{ steps.cache-env.outputs.key }}
restore-keys: ${{ steps.cache-env.outputs.key }}

- name: Install PHP with extensions
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
extensions: ${{ env.extensions }}
ini-values: date.timezone='UTC'
coverage: pcov

- name: Determine composer cache directory
if: matrix.os == 'ubuntu-latest'
run: echo "COMPOSER_CACHE_DIR=$(composer config cache-dir)" >> $GITHUB_ENV

- name: Cache dependencies installed with composer
uses: actions/cache@v2
with:
path: ${{ env.COMPOSER_CACHE_DIR }}
key: php${{ matrix.php-version }}-composer-${{ matrix.dependencies }}-${{ hashFiles('**/composer.json') }}
restore-keys: |
php${{ matrix.php-version }}-composer-${{ matrix.dependencies }}-

- name: Install dependencies with composer
if: matrix.php-version != '8.1'
run: composer update --prefer-dist --no-interaction --no-progress --optimize-autoloader --ansi

- name: Install dependencies with composer php 8.1
if: matrix.php-version == '8.1'
run: composer update --ignore-platform-reqs --prefer-dist --no-interaction --no-progress --optimize-autoloader --ansi

- name: Run oracle tests with phpunit
env:
DB: oracle
run: vendor/bin/phpunit --group driver-oracle --colors=always
145 changes: 145 additions & 0 deletions src/Driver/Oracle/OracleCompiler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php

declare(strict_types=1);

namespace Cycle\Database\Driver\Oracle;

use Cycle\Database\Driver\Compiler;
use Cycle\Database\Driver\Quoter;
use Cycle\Database\Injection\Fragment;
use Cycle\Database\Injection\Parameter;
use Cycle\Database\Query\QueryParameters;

class OracleCompiler extends Compiler
{
/**
* Column to be used as ROW_NUMBER in fallback selection mechanism, attention! Amount of columns
* in result set will be increaced by 1!
*/
public const ROW_NUMBER = '_ROW_NUMBER_';

/**
* {@inheritdoc}
*
* Attention, limiting and ordering UNIONS will fail in SQL SERVER < 2012.
* For future upgrades: think about using top command.
*
* @link http://stackoverflow.com/questions/603724/how-to-implement-limit-with-microsoft-sql-server
* @link http://stackoverflow.com/questions/971964/limit-10-20-in-sql-server
*/
protected function selectQuery(QueryParameters $params, Quoter $q, array $tokens): string
{
$limit = $tokens['limit'];
$offset = $tokens['offset'];

if (($limit === null && $offset === null) || $tokens['orderBy'] !== []) {
//When no limits are specified we can use normal query syntax
return call_user_func_array([$this, 'baseSelect'], func_get_args());
}

/**
* We are going to use fallback mechanism here in order to properly select limited data from
* database. Avoid usage of LIMIT/OFFSET without proper ORDER BY statement.
*
* Please see set of alerts raised in SelectQuery builder.
*/
$tokens['columns'][] = new Fragment(
sprintf(
'ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS %s',
$this->name($params, $q, self::ROW_NUMBER)
)
);

$tokens['limit'] = null;
$tokens['offset'] = null;

return sprintf(
"SELECT * FROM (\n%s\n) AS [ORD_FALLBACK] %s",
$this->baseSelect($params, $q, $tokens),
$this->limit($params, $q, $limit, $offset, self::ROW_NUMBER)
);
}

/**
* @inheritDoc
*
* SELECT ... FOR UPDATE
* @see https://www.devtechinfo.com/oracle-plsql-select-update-statement/
* CURSOR_cursor name IS select_statement FOR UPDATE [OF column_list] [NOWAIT];
*/
private function baseSelect(QueryParameters $params, Quoter $q, array $tokens): string
{
// This statement(s) parts should be processed first to define set of table and column aliases
$tables = [];
foreach ($tokens['from'] as $table) {
$tables[] = $this->name($params, $q, $table, true);
}

$joins = $this->joins($params, $q, $tokens['join']);

return sprintf(
"SELECT%s %s\nFROM %s%s%s%s%s%s%s%s%s",
$this->optional(' ', $this->distinct($params, $q, $tokens['distinct'])),
$this->columns($params, $q, $tokens['columns']),
implode(', ', $tables),
$this->optional(' ', $joins, ' '),
$this->optional("\nWHERE", $this->where($params, $q, $tokens['where'])),
$this->optional("\nGROUP BY", $this->groupBy($params, $q, $tokens['groupBy']), ' '),
$this->optional("\nHAVING", $this->where($params, $q, $tokens['having'])),
$this->optional("\n", $this->unions($params, $q, $tokens['union'])),
$this->optional("\nORDER BY", $this->orderBy($params, $q, $tokens['orderBy'])),
$this->optional("\n", $this->limit($params, $q, $tokens['limit'], $tokens['offset'])),
$this->optional(' ', $tokens['forUpdate'] ? 'FOR UPDATE' : '', ' ')
);
}

/**
* {@inheritdoc}
*
* @param string $rowNumber Row used in a fallback sorting mechanism, ONLY when no ORDER BY
* specified.
*
* @link https://www.oracletutorial.com/oracle-basics/oracle-fetch/
*/
protected function limit(
QueryParameters $params,
Quoter $q,
int $limit = null,
int $offset = null,
string $rowNumber = null
): string {
if ($limit === null && $offset === null) {
return '';
}

//Modern SQLServer are easier to work with
if ($rowNumber === null) {
$statement = 'OFFSET ? ROWS ';
$params->push(new Parameter((int)$offset));

if ($limit !== null) {
$statement .= 'FETCH FIRST ? ROWS ONLY';
$params->push(new Parameter($limit));
}

return trim($statement);
}

$statement = "WHERE {$this->name($params, $q, $rowNumber)} ";

//0 = row_number(1)
++$offset;

if ($limit !== null) {
$statement .= 'BETWEEN ? AND ?';
$params->push(new Parameter((int)$offset));
$params->push(new Parameter($offset + $limit - 1));
} else {
$statement .= '>= ?';
$params->push(new Parameter((int)$offset));
}

return $statement;
}

}
135 changes: 135 additions & 0 deletions src/Driver/Oracle/OracleDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

declare(strict_types=1);

namespace Cycle\Database\Driver\Oracle;

use Cycle\Database\Driver\Driver;
use Cycle\Database\Exception\StatementException;
use Cycle\Database\Query\QueryBuilder;
use Throwable;

class OracleDriver extends Driver
{
/**
* Default public schema name for all postgres connections.
*
* @var non-empty-string
*/
public const PUBLIC_SCHEMA = 'SYSTEM';

/**
* Option key for all available postgres schema names.
*
* @var non-empty-string
*/
private const OPT_AVAILABLE_SCHEMAS = 'schema';

/**
* Schemas to search tables in
*
* @var string[]
* @psalm-var non-empty-array<non-empty-string>
*/
private array $searchSchemas = [];

/**
* @param array $options
*/
public function __construct(array $options)
{
parent::__construct(
$options,
new OracleHandler(),
new OracleCompiler('""'),
QueryBuilder::defaultBuilder()
);

$this->defineSchemas($this->options);
}

/**
* @inheritDoc
*/
public function getType(): string
{
return 'Oracle';
}

/**
* @inheritDoc
*/
public function getSource(): string
{
// remove "oci:"
return substr($this->getDSN(), 4);
}

/**
* Schemas to search tables in
*
* @return string[]
*/
public function getSearchSchemas(): array
{
return $this->searchSchemas;
}

/**
* Check if schemas are defined
*
* @return bool
*/
public function shouldUseDefinedSchemas(): bool
{
return $this->searchSchemas !== [];
}

/**
* Parse the table name and extract the schema and table.
*
* @param string $name
* @return string[]
*/
public function parseSchemaAndTable(string $name): array
{
$schema = null;
$table = $name;

if (str_contains($name, '.')) {
[$schema, $table] = explode('.', $name, 2);

if ($schema === '$user') {
$schema = $this->options['username'];
}
}

return [$schema ?? $this->searchSchemas[0], $table];
}

/**
* @inheritDoc
*/
protected function mapException(Throwable $exception, string $query): StatementException
{
if ((int)$exception->getCode() === 23000) {
return new StatementException\ConstrainException($exception, $query);
}

return new StatementException($exception, $query);
}

/**
* Define schemas from config
*/
private function defineSchemas(array $options): void
{
$options[self::OPT_AVAILABLE_SCHEMAS] = (array)($options[self::OPT_AVAILABLE_SCHEMAS] ?? []);

$defaultSchema = static::PUBLIC_SCHEMA;

$this->searchSchemas = array_values(array_unique(
[$defaultSchema, ...$options[self::OPT_AVAILABLE_SCHEMAS]]
));
}
}
Loading