diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b902644 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: ['8.1', '8.2', '8.3'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + tools: composer:v2 + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction + + - name: Run tests + run: vendor/bin/phpunit diff --git a/.travis.yml b/.travis.yml index a856367..c97e95a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,7 @@ php: - 7.1 - 7.2 - 7.3 + - 7.4 services: mysql diff --git a/README.md b/README.md index d122686..9be771e 100644 --- a/README.md +++ b/README.md @@ -1,109 +1,309 @@ -DB ![Build Status](https://travis-ci.org/objectiveweb/db.svg?branch=master) -== +# objectiveweb/db -Getting Started ---------------- +Small database abstraction layer built on top of Doctrine DBAL. - use Objectiveweb\DB; +## Install - $db = new DB('pdo uri', 'username', 'password'); +```bash +composer require objectiveweb/db doctrine/dbal +``` - // general queries - $db->query('create table ...')->exec(); +## Getting started - // insert - $insert_id = $db->insert('table', array('field' => 'value', 'otherfield' => 'value')); +```php +use Objectiveweb\DB; - // update (table, values, conditions) - $affected_rows = $db->update('table', array('field' => 'newvalue', ...), array('field' => 'value')); +$db = new DB('mysql:dbname=app;host=127.0.0.1', 'user', 'secret', [ + 'prefix' => 'myprefix_', +]); +``` - // select all rows - // returns array ( row1, row2, ...) - $rows = $db->select('table')->all(); +```php +use Objectiveweb\DB; - // map results by field - // returns associative array { 'row1_field_value' => row1, 'row2_field_value' => row2, ...) - $rows = $db->select('table')->map('field'); +// Raw query +$db->query('CREATE TABLE users (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255))')->exec(); - // select IN - $rows = $db->select('table', array('ID' => array( 1, 2, 3))->all(); +// Insert +$insertId = $db->insert('users', ['name' => 'Alice']); - // fetch row by row - $query = $db->select('table'); +// Update (table, values, conditions) +$affectedRows = $db->update('users', ['name' => 'Alice Smith'], ['id' => 1]); - while($row = $query->fetch()) { - // process $row +// Select all rows +$rows = $db->select('users')->all(); + +// Select with IN +$rows = $db->select('users', ['id' => [1, 2, 3]])->all(); + +// Select with LIKE +$rows = $db->select('users', ['name' => 'Ali%'])->all(); + +// Select with SQL functions +$stats = $db->select('users', [], [ + 'fields' => [ + 'total' => 'COUNT(*)', + 'avg_age' => 'AVG(age)', + 'max_age' => 'MAX(age)', + ], +])->fetch(); + +// Map by field +$byId = $db->select('users')->map('id'); + +// Fetch row by row +$query = $db->select('users'); +while ($row = $query->fetch()) { + // process +} + +// Delete +$affectedRows = $db->delete('users', ['id' => 1]); + +// Transaction +$db->transaction(function (DB $db) { + $id = $db->insert('users', ['name' => 'Bob']); + $db->update('users', ['name' => 'Bobby'], ['id' => $id]); + + return $id; +}); +``` + +## CRUD operations + +```php +use Objectiveweb\DB; + +$db = new DB('mysql:dbname=app;host=127.0.0.1', 'user', 'secret'); + +$table = $db->table('users', [ + 'pk' => 'id', + 'join' => [], + 'model' => null, // optional: class-string to map rows into objects +]); + +// Insert +$id = $table->insert(['name' => 'Alice']); + +// Select all rows (returns Objectiveweb\DB\Collection) +$data = $table->select(); + +// Filter/sort/range +$data = $table->select([], [ + 'filter' => ['name' => 'Alice'], + 'sort' => [ + ['last_name', 'asc'], + ['id', 'desc'], + ], + 'range' => [0, 9], +]); + +$count = count($data); +$total = $data->total(); +$contentRange = $data->contentRange(); + +foreach ($data as $item) { + $item['name']; +} + +// Update by filter +$updated = $table->update(['name' => 'Alice'], ['name' => 'Alice Smith']); + +// Update by ID +$updated = $table->update(1, ['name' => 'Alice Smith']); +``` + +`select($filter, $params)` rule: when `$params['filter']` is provided, it is merged with `$filter` (`array_merge($filter, $params['filter'])`), so keys in `$params['filter']` win on conflicts. + +### Model mapping (optional) + +```php +use Objectiveweb\DB\Model; + +final class UserModel extends Model +{ + protected static array $validFields = ['name', 'age']; + + protected static array $creationRules = [ + 'name' => [ + 'required' => true, + 'filter' => FILTER_UNSAFE_RAW, + 'validate' => [self::class, 'validateName'], + ], + 'age' => [ + 'required' => true, + 'filter' => FILTER_VALIDATE_INT, + 'validate' => [self::class, 'validateAge'], + ], + ]; + + public static function validateName(mixed $value): bool|string + { + return is_string($value) && strlen($value) >= 2; } - // delete (table, conditions) - $db->delete('table', array('field' => 'value')); + public static function validateAge(mixed $value): bool|string + { + return is_int($value) && $value >= 0; + } +} - // transactions - $db->transaction(function() use ($somevar) { - $id = $db->insert(...); - $db->update(...); +$table = $db->table('users', ['model' => UserModel::class]); +$users = $table->select(); // Collection +$one = $table->get(1); // UserModel - if($condition) { - throw new \Exception('Error - transaction rolled back'); - } else { - return $id; - } - }); +// create/update payload is auto-filtered/validated against the model +$table->insert(['name' => 'Alice', 'age' => 31, 'ignored' => 'x']); // "ignored" is dropped +``` -CRUD Operations ---------------- +## Extending `DB\\Table` - use Objectiveweb\DB; +```php +use Objectiveweb\DB\Table; - $db = new DB(...); +class UserTable extends Table +{ + protected ?string $table = 'users'; - $table = $db->table('tablename', [ + protected ?array $params = [ 'pk' => 'id', - 'join' => [] - ]); + 'join' => [], + ]; +} - // Insert - $id = $table->post(array('field' => 'value', ...); +$table = $db->table(UserTable::class); +$table->insert(['name' => 'Alice']); +``` - // Select all rows (returns DB\Collection) - $table->index(); +## Filter grammar - // Get parameters - $data = $table->index([ - 'filter' => [ 'field' => 'value' ], - 'sort' => ['id', 'asc'], - 'range' => [ 0, 4 ] - ]); +`where` arrays support: - // Number of results - count($data); +- equality: `['id' => 10]` +- negation: `['!status' => 'archived']` +- `LIKE`: `['name' => 'Jo%']` +- `IN`: `['id' => [1, 2, 3]]` +- null checks: `['deleted_at' => null]`, `['!deleted_at' => null]` - // Total number of results (when using range) - $data->total(); +## SQL function fields - foreach($data as $item) { - $item['field']; - } +You can use SQL functions in `params['fields']` and alias them with array keys. - // Update (key, values) - $affected_rows = $table->put(array('name' = 'new name'), array('name' => 'old name')); +```php +$row = $db->select('orders', ['status' => 'paid'], [ + 'fields' => [ + 'total_orders' => 'COUNT(*)', + 'avg_total' => 'AVG(total)', + 'max_total' => 'MAX(total)', + ], +])->fetch(); +``` - // Update by ID - $affected_rows = $table->put(id, array('field' => 'new value')); +Grouped aggregate example: +```php +$rows = $db->select('orders', null, [ + 'fields' => [ + 'customer_id', + 'orders_count' => 'COUNT(*)', + 'avg_total' => 'AVG(total)', + ], + 'group' => 'customer_id', + 'order' => 'customer_id ASC', +])->all(); +``` -Extending DB\Table ------------------- +## Joins - class MyTable extends Objectiveweb\DB\Table { - var $table = 'table_name'; - var $params = [ - 'pk => 'id', - 'join' => [] - ]; - } +`select()` accepts joins via `params['join']`. + +### Legacy join map (backward compatible) + +```php +$rows = $db->select('person_links', [], [ + 'join' => [ + 'inner:people p1' => 'p1.id = person_links.parent_a_id', + 'left:people p2' => 'p2.id = person_links.parent_b_id', + 'right:people p3' => 'p3.id = person_links.child_id', + 'full:people p4' => 'p4.id = person_links.child_id', + 'cross:calendar c' => '', + 'festival f' => 'f.id = person_links.child_id', // no prefix => plain JOIN (DB default) + ], +]); +``` + +### Structured join list (recommended) + +```php +$rows = $db->select('person_links', [], [ + 'join' => [ + ['type' => 'inner', 'table' => 'people', 'alias' => 'p1', 'on' => 'p1.id = person_links.parent_a_id'], + ['type' => 'left', 'table' => 'people', 'alias' => 'p2', 'on' => 'p2.id = person_links.parent_b_id'], + ['type' => 'right', 'table' => 'people', 'alias' => 'p3', 'on' => 'p3.id = person_links.child_id'], + ['type' => 'full', 'table' => 'people', 'alias' => 'p4', 'on' => 'p4.id = person_links.child_id'], + ['type' => 'cross', 'table' => 'calendar', 'alias' => 'c'], + ], +]); +``` + +Supported `type` values: +- `inner` +- `left` +- `right` +- `full` (rendered as `FULL OUTER JOIN`) +- `cross` + +Dialect note: +- `RIGHT JOIN` is not supported by SQLite. +- `FULL OUTER JOIN` is PostgreSQL-only in this project test matrix. + +## Row locking + +`select()` supports row-level locks via `params['lock']`. + +```php +$row = $db->select('users', ['id' => 1], [ + 'lock' => 'update', // or true + 'limit' => 1, +])->fetch(); +``` + +Supported values: +- `true`, `'update'`, `'for update'` => `FOR UPDATE` +- `'share'`, `'for share'` => `FOR SHARE` +- `null`, `false`, `''` => no lock clause + +Notes: +- SQLite does not support `FOR UPDATE`/`FOR SHARE`. +- Invalid lock values throw `Objectiveweb\DB\Exception\InvalidQueryException`. + +## Notes + +- Table and field identifiers are validated before SQL generation. +- Raw string `where` clauses and raw join fragments are intentionally rejected for safety. +- `Collection::render()` does not emit HTTP headers. Use `Collection::contentRange()` if you need a `Content-Range` response header. + +## Migration notes + +- Low-level internals moved from direct PDO usage to Doctrine DBAL. +- Pagination totals now use a dedicated `COUNT(*)` query instead of `SQL_CALC_FOUND_ROWS`. +- Transaction helpers throw typed exceptions (`TransactionException`) when begin/commit/rollback fails. +- Test suite defaults to SQLite in-memory, so local MySQL is no longer required. + +## Stability policy + +- Semantic Versioning is used for public APIs. +- Public stable APIs: `Objectiveweb\DB`, `Objectiveweb\DB\Table`, `Objectiveweb\DB\Collection`, `Objectiveweb\DB\Query`. +- Internal/private helpers in `DB` (identifier parsing/compilation methods) are not part of the public contract. + +## Test matrix (SQLite + MySQL + PostgreSQL) + +- Default local run (SQLite): `vendor/bin/phpunit --testsuite sqlite` +- MySQL run: `TEST_DB_DRIVER=mysql MYSQL_TEST_DSN=\"mysql:dbname=objectiveweb_test;host=127.0.0.1;port=3306;charset=utf8mb4\" MYSQL_TEST_USER=root MYSQL_TEST_PASSWORD=root vendor/bin/phpunit --testsuite mysql` +- PostgreSQL run: `TEST_DB_DRIVER=pgsql PGSQL_TEST_DSN=\"pgsql:dbname=objectiveweb_test;host=127.0.0.1;port=5432\" PGSQL_TEST_USER=postgres PGSQL_TEST_PASSWORD=postgres vendor/bin/phpunit --testsuite pgsql` - // then, instantiate it - $table = $db->table('MyTable'); +### Run all databases in Docker - $table->post(array('name' => 'new item')); +```bash +./scripts/test-docker.sh +``` diff --git a/composer.json b/composer.json index 334b6d8..867fea6 100644 --- a/composer.json +++ b/composer.json @@ -15,10 +15,11 @@ } ], "require": { - "php": ">=5.6.0" + "php": ">=8.0", + "doctrine/dbal": "^3.10 || ^4.0" }, "require-dev": { - "phpunit/phpunit": "4.*" + "phpunit/phpunit": "^9.6" }, "autoload": { "psr-4": {"Objectiveweb\\": "src/"} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3e0461e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +services: + mysql: + image: mysql:8.4 + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: objectiveweb_test + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot"] + interval: 5s + timeout: 5s + retries: 20 + + pgsql: + image: postgres:16 + environment: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: objectiveweb_test + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d objectiveweb_test"] + interval: 5s + timeout: 5s + retries: 20 + + app: + build: + context: . + dockerfile: docker/php/Dockerfile + working_dir: /app + volumes: + - ./:/app + depends_on: + mysql: + condition: service_healthy + pgsql: + condition: service_healthy + environment: + MYSQL_TEST_DSN: mysql:dbname=objectiveweb_test;host=mysql;port=3306;charset=utf8mb4 + MYSQL_TEST_USER: root + MYSQL_TEST_PASSWORD: root + MYSQL_TEST_HOST: mysql + MYSQL_TEST_PORT: "3306" + MYSQL_TEST_DB: objectiveweb_test + PGSQL_TEST_DSN: pgsql:dbname=objectiveweb_test;host=pgsql;port=5432 + PGSQL_TEST_USER: postgres + PGSQL_TEST_PASSWORD: postgres + PGSQL_TEST_HOST: pgsql + PGSQL_TEST_PORT: "5432" + PGSQL_TEST_DB: objectiveweb_test + command: ["bash", "-lc", "sleep infinity"] diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile new file mode 100644 index 0000000..68dd1eb --- /dev/null +++ b/docker/php/Dockerfile @@ -0,0 +1,17 @@ +FROM php:8.4-cli + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + unzip \ + libpq-dev \ + libsqlite3-dev \ + default-mysql-client \ + postgresql-client \ + && docker-php-ext-install pdo_mysql pdo_pgsql pdo_sqlite \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +RUN git config --global --add safe.directory /app + +WORKDIR /app diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..f9ca27f --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,24 @@ + + + + + test + + + + test + test/MySQLCompatibilityTest.php + test/PgSQLCompatibilityTest.php + + + + test + test/PgSQLCompatibilityTest.php + + + + test + test/MySQLCompatibilityTest.php + + + diff --git a/scripts/test-all-databases.sh b/scripts/test-all-databases.sh new file mode 100755 index 0000000..38e97ec --- /dev/null +++ b/scripts/test-all-databases.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +git config --global --add safe.directory /app || true +export COMPOSER_ROOT_VERSION="${COMPOSER_ROOT_VERSION:-dev-main}" + +composer install --no-interaction --prefer-dist + +echo "==> Running SQLite suite" +TEST_DB_DRIVER=sqlite vendor/bin/phpunit --testsuite sqlite + +echo "==> Running MySQL suite" +TEST_DB_DRIVER=mysql vendor/bin/phpunit --testsuite mysql + +echo "==> Running PostgreSQL suite" +TEST_DB_DRIVER=pgsql vendor/bin/phpunit --testsuite pgsql diff --git a/scripts/test-docker.sh b/scripts/test-docker.sh new file mode 100755 index 0000000..fa30af3 --- /dev/null +++ b/scripts/test-docker.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +COMPOSE=${COMPOSE_CMD:-"docker compose"} + +# shellcheck disable=SC2086 +$COMPOSE up -d --build +# shellcheck disable=SC2086 +$COMPOSE exec app bash -lc ./scripts/test-all-databases.sh diff --git a/src/DB.php b/src/DB.php index 5bf394a..88bbafd 100644 --- a/src/DB.php +++ b/src/DB.php @@ -1,418 +1,873 @@ setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - $this->pdo = $pdo; - $this->prefix = $prefix; - } - /** - * Creates a new DB instance - * - * @param string $dsn driver:dbname=name;host=127.0.0.1;charset=utf8 - * - * The Data Source Name, or DSN, contains the information required to connect to the database. - * - * In general, a DSN consists of the PDO driver name, followed by a colon, followed by the PDO driver-specific connection syntax. Further information is available from the PDO driver-specific documentation. - * - * The dsn parameter supports three different methods of specifying the arguments required to create a database connection: - * - * Driver invocation - * dsn contains the full DSN. - * - * URI invocation - * dsn consists of uri: followed by a URI that defines the location of a file containing the DSN string. The URI can specify a local file or a remote URL. - * - * uri:file:///path/to/dsnfile - * - * Aliasing - * dsn consists of a name name that maps to pdo.dsn.name in php.ini defining the DSN string. - * - * @param string $username - * The user name for the DSN string. This parameter is optional for some PDO drivers. - * - * @param string $password - * The password for the DSN string. This parameter is optional for some PDO drivers. - * - * @param array $options - * PDO key=>value array of driver-specific connection options. - * - * @return \Objectiveweb\DB - */ - public static function connect($dsn, $username = null, $password = '', $options = array()) - { + $this->prefix = isset($options['prefix']) ? (string) $options['prefix'] : ''; + unset($options['prefix']); - // parse dsn if necessary if (is_array($dsn)) { - $username = $dsn['user']; - $password = @$dsn['pass']; - $dsn = sprintf("%s:dbname=%s;host=%s;charset=utf8", - $dsn['scheme'], - isset($dsn['dbname']) ? $dsn['dbname'] : substr($dsn['path'], 1), - $dsn['host']); + $params = self::fromParsedUrl($dsn, $username, $password, $options); + } else { + $params = self::fromDsnString($dsn, $username, $password, $options); } - $prefix = @$options['prefix']; - unset($options['prefix']); - - return new DB(new PDO($dsn, $username, $password, $options), $prefix); - + $this->connection = DriverManager::getConnection($params); } - function query($sql) + public function query(string $sql, mixed ...$args): Query { - if (func_num_args() > 1) { - $sql = call_user_func_array('sprintf', func_get_args()); + if ($args !== []) { + $sql = sprintf($sql, ...$args); } - $stmt = $this->pdo->prepare($sql); - - $query = new Query($stmt); + $query = new Query($this->connection, $sql); + $query->debugSql = $sql; if ($this->debug) { - $query->sql = $sql; + error_log($sql); } return $query; } - /* Transactions ------------------------------------------------ */ - - function beginTransaction() + public function beginTransaction(): bool { - return $this->pdo->beginTransaction(); + try { + $this->connection->beginTransaction(); + return true; + } catch (\Throwable $e) { + throw new TransactionException('Cannot begin transaction', 500, $e); + } } - function rollBack() + public function rollBack(): bool { - return $this->pdo->rollBack(); + try { + $this->connection->rollBack(); + return true; + } catch (\Throwable $e) { + throw new TransactionException('Cannot rollback transaction', 500, $e); + } } - /** - * Returns TRUE on success or FALSE on failure. - */ - function commit() + public function commit(): bool { - return $this->pdo->commit(); + try { + $this->connection->commit(); + return true; + } catch (\Throwable $e) { + throw new TransactionException('Cannot commit transaction', 500, $e); + } } - function transaction($callable) + public function transaction(callable $callable): mixed { $this->beginTransaction(); try { - $ret = call_user_func($callable, $this); - - if(!$this->commit()) { - throw new \Exception('Cannot commit transaction', 500); - } + $ret = $callable($this); + $this->commit(); return $ret; - } catch (\Exception $ex) { - $this->rollBack(); + } catch (\Throwable $ex) { + try { + $this->rollBack(); + } catch (TransactionException $rollbackError) { + throw new TransactionException('Transaction rollback failed after error', 500, $rollbackError); + } throw $ex; } } - /* sql helpers ------------------------------------------------ */ - /** - * Performs a SELECT Query - * @param $table - * @param $where array [ field => value ] or string - * @param array $params [ key => value ] - * fields => comma-separated string or array. - * Non-numeric keys are used as field names, for example - * $fields = array( 'id', 'name', 'total' => 'COUNT(*)' ); - * group => null - * order => null - * limit => null - * offset => 0 - * join => array( - * 'table' => 'table.id = other.id', // table -> condition syntax - * 'othertable t on t.id = table.id' // raw string syntax - * ) - * - * @return \Objectiveweb\DB\Query - * @throws \Exception + * @param array|string|null $where + * @param array $params */ - function select($table, $where = null, $params = array()) + public function select(string $table, array|string|null $where = null, array $params = []): Query { - - $defaults = array( - 'fields' => '*', - 'group' => NULL, - 'order' => NULL, - 'limit' => NULL, + $defaults = [ + 'fields' => ['*'], + 'group' => null, + 'order' => null, + 'limit' => null, 'offset' => 0, - 'join' => '' - ); + 'join' => [], + 'lock' => null, + ]; $params = array_merge($defaults, $params); - /** - * JOIN - */ - if (is_array($params['join'])) { - $join = ''; - foreach ($params['join'] as $k => $v) { - if (is_numeric($k)) { - $join .= " $v"; - } else { - if ($k[0] == '*') { - $join .= ' left'; - $k = ltrim($k, '*'); - } else { - $join .= ' inner'; - } - $join .= " join {$this->prefix}{$k} {$k} on {$v}"; - } - } - } else { - $join = $params['join']; + $tableAlias = $this->assertIdentifier($table); + $tableName = $this->prefix . $tableAlias; + + $fields = $this->compileFields($params['fields']); + [$joinSql, $joinBindings] = $this->compileJoin((array) $params['join'], $tableAlias); + [$whereSql, $whereBindings] = $this->buildWhereClause($where); + + $sql = sprintf( + 'SELECT %s FROM %s %s%s%s', + implode(', ', $fields), + $this->quoteIdentifier($tableName), + $this->quoteIdentifier($tableAlias), + $joinSql !== '' ? ' ' . $joinSql : '', + $whereSql !== '' ? ' WHERE ' . $whereSql : '' + ); + + if (!empty($params['group'])) { + $sql .= ' GROUP BY ' . $this->compileGroup($params['group']); + } + + if (!empty($params['order'])) { + $sql .= ' ORDER BY ' . $this->compileOrder($params['order']); } - /** - * FIELDS - */ - if (!is_array($params['fields'])) { - $params['fields'] = explode(",", $params['fields']); + if ($params['limit'] !== null) { + $sql .= sprintf(' LIMIT %d OFFSET %d', (int) $params['limit'], (int) $params['offset']); } - $fields = array(); + $sql .= $this->compileLockClause($params['lock'] ?? null); - foreach ($params['fields'] as $k => $v) { + $query = $this->query($sql); + $query->exec(array_merge($joinBindings, $whereBindings)); - // Allow * and functions - if (preg_match('/(\*|[A-Z]+\([^\)]+\)|[a-z]+\([^\)]+\)|SQL_CALC_FOUND_ROWS.*)/', $v)) { - $r = str_replace('`', '``', $v); - } else { - $r = "`" . implode('`.`', explode(".", str_replace('`', '``', $v))) . "`"; - } + return $query; + } - if (!is_numeric($k)) { - $r .= sprintf(" as `%s`", str_replace('`', '``', $k)); - } + /** + * @param array|string|null $where + * @param array $params + */ + public function count(string $table, array|string|null $where = null, array $params = []): int + { + $params = array_merge([ + 'join' => [], + 'group' => null, + ], $params); + + $tableAlias = $this->assertIdentifier($table); + $tableName = $this->prefix . $tableAlias; + + [$joinSql, $joinBindings] = $this->compileJoin((array) $params['join'], $tableAlias); + [$whereSql, $whereBindings] = $this->buildWhereClause($where); + + $base = sprintf( + ' FROM %s %s%s%s', + $this->quoteIdentifier($tableName), + $this->quoteIdentifier($tableAlias), + $joinSql !== '' ? ' ' . $joinSql : '', + $whereSql !== '' ? ' WHERE ' . $whereSql : '' + ); + + $bindings = array_merge($joinBindings, $whereBindings); - //$fields[] = $r; - $params['fields'][$k] = $r; + if (!empty($params['group'])) { + $groupSql = $this->compileGroup($params['group']); + $sql = 'SELECT COUNT(*) AS count FROM (SELECT 1' . $base . ' GROUP BY ' . $groupSql . ') ow_count'; + } else { + $sql = 'SELECT COUNT(*) AS count' . $base; } - list($where, $bindings) = $this->_where($where); + $result = $this->query($sql); + $result->exec($bindings); + $countRow = $result->fetch(); - $sql = sprintf(/** @lang text */ - "SELECT %s FROM `%s` %s %s %s", - implode(", ", $params['fields']), - $this->prefix . $table, - $table, - $join, - !empty($where) ? 'WHERE ' . $where : ''); + return isset($countRow['count']) ? (int) $countRow['count'] : 0; + } - if ($params['group']) { - if (is_array($params['group'])) { - throw new \Exception('not implemented'); - } else { - $sql .= sprintf(' GROUP BY %s', $params['group']); - } + /** @param array $data */ + public function insert(string $table, array $data): ?string + { + if ($data === []) { + throw new InvalidQueryException('Nothing to INSERT'); } - if (!empty($params['order'])) { - if (is_array($params['order'])) { - $params['order'] = implode(' ', $params['order']); - } - $sql .= sprintf(' ORDER BY %s', $params['order']); - } + $tableName = $this->prefix . $this->assertIdentifier($table); + $columns = []; + $placeholders = []; + $bindings = []; - if ($params['limit']) { - $sql .= sprintf(' LIMIT %d,%d', $params['offset'], $params['limit']); + foreach ($data as $field => $value) { + $column = $this->assertIdentifier((string) $field); + $columns[] = $this->quoteIdentifier($column); + $placeholders[] = ':' . $column; + $bindings[$column] = is_bool($value) ? (int) $value : $value; } - $query = $this->query($sql); + $sql = sprintf( + 'INSERT INTO %s (%s) VALUES (%s)', + $this->quoteIdentifier($tableName), + implode(', ', $columns), + implode(', ', $placeholders) + ); - $query->exec($bindings); + $affected = $this->query($sql)->exec($bindings); - return $query; + return $affected === 0 ? null : (string) $this->connection->lastInsertId(); } /** - * Inserts $data into $table - * - * @param $table - * @param $data array [ field => value, ... ] - * @return $id int Last Insert ID or NULL if no rows where changed + * @param array $data + * @param array|string|null $where */ - function insert($table, $data) + public function update(string $table, array $data, array|string|null $where = null, ?int $limit = null): int { + if ($data === []) { + throw new InvalidQueryException('Nothing to UPDATE'); + } - $fields = array_keys($data); + [$whereSql, $whereBindings] = $this->buildWhereClause($where); + if ($whereSql === '') { + throw new InvalidQueryException('Unsafe UPDATE without WHERE clause'); + } - $sql = "INSERT INTO " . $this->prefix . $table . " (" . implode(", ", $fields) . ") VALUES (:" . implode(", :", $fields) . ");"; + $tableName = $this->prefix . $this->assertIdentifier($table); + $changes = []; + $bindings = $whereBindings; - $query = $this->query($sql); - foreach ($fields as $field) { - $query->bind($field, $data[$field]); + foreach ($data as $key => $value) { + $field = $this->assertIdentifier((string) $key); + $placeholder = 'update_' . $field; + $changes[] = sprintf('%s = :%s', $this->quoteIdentifier($field), $placeholder); + $bindings[$placeholder] = is_bool($value) ? (int) $value : $value; } - $rows = $query->exec(); + $sql = sprintf( + 'UPDATE %s SET %s WHERE %s%s', + $this->quoteIdentifier($tableName), + implode(', ', $changes), + $whereSql, + $limit !== null ? sprintf(' LIMIT %d', $limit) : '' + ); + + return $this->query($sql)->exec($bindings); + } + + /** @param array|string|null $where */ + public function delete(string $table, array|string|null $where): int + { + [$whereSql, $whereBindings] = $this->buildWhereClause($where); + if ($whereSql === '') { + throw new InvalidQueryException('Unsafe DELETE without WHERE clause'); + } + + $tableName = $this->prefix . $this->assertIdentifier($table); + + $sql = sprintf( + 'DELETE FROM %s WHERE %s', + $this->quoteIdentifier($tableName), + $whereSql + ); - return ($rows === 0) ? NULL : $this->pdo->lastInsertId(); + return $this->query($sql)->exec($whereBindings); } + public function debug(bool $status = true): void + { + $this->debug = $status; + } + + /** @param array $params */ + public function table(string $table, array $params = ['pk' => 'id']): Table + { + if (class_exists($table) && is_subclass_of($table, Table::class)) { + return new $table($this); + } + + return new Table($this, $table, $params); + } /** - * UPDATE - * - * @param String $table Table name - * @param array $data Data to update - * @param mixed $where conditions - * @return int number of updated rows - * @throws \Exception + * @param array $array + * @param list|string $validKeys + * @param array $defaults + * @return array */ - function update($table, $data, $where = null) + public static function array_cleanup(array $array, array|string $validKeys = [], array $defaults = []): array { + $keys = is_array($validKeys) ? $validKeys : [$validKeys]; + $cleanArray = array_intersect_key($array, array_flip($keys)); + return array_merge($defaults, $cleanArray); + } - $changes = array(); + public static function now(): string + { + return date('Y-m-d H:i:s'); + } - list($where, $bindings) = $this->_where($where); + /** + * @param array|string|null $args + * @return array{0:string,1:array} + */ + private function buildWhereClause(array|string|null $args = null, string $glue = 'AND'): array + { + if ($args === null || $args === '') { + return ['', []]; + } - foreach ($data as $key => $value) { - $changes[] = "$key = :update_$key"; - $bindings[":update_$key"] = $value; + if (is_string($args)) { + throw new InvalidQueryException('Raw WHERE string is disabled. Use array conditions.'); } - if (empty($changes)) { - throw new \Exception("Nothing to UPDATE"); + $cond = []; + $bindings = []; + + foreach ($args as $key => $value) { + if (!is_string($key) || $key === '') { + throw new InvalidQueryException('Invalid WHERE key'); + } + + $not = false; + if ($key[0] === '!') { + $not = true; + $key = substr($key, 1); + } + + $field = $this->quoteIdentifierPath($this->assertIdentifier($key)); + $baseName = 'where_' . preg_replace('/[^A-Za-z0-9_]/', '_', $key) . '_' . count($bindings); + + if (is_array($value)) { + if ($value === []) { + $cond[] = $not ? '1=1' : '1=0'; + continue; + } + + $list = []; + foreach (array_values($value) as $i => $item) { + $name = $baseName . '_' . $i; + $list[] = ':' . $name; + $bindings[$name] = is_bool($item) ? (int) $item : $item; + } + + $cond[] = sprintf('%s %sIN (%s)', $field, $not ? 'NOT ' : '', implode(', ', $list)); + continue; + } + + if ($value === null) { + $cond[] = sprintf('%s IS %sNULL', $field, $not ? 'NOT ' : ''); + continue; + } + + $operator = '='; + if (is_string($value) && strpos($value, '%') !== false) { + $operator = $not ? 'NOT LIKE' : 'LIKE'; + } elseif ($not) { + $operator = '<>'; + } + + $cond[] = sprintf('%s %s :%s', $field, $operator, $baseName); + $bindings[$baseName] = is_bool($value) ? (int) $value : $value; } - $sql = sprintf(/** @lang text */ - "UPDATE `%s` SET %s WHERE %s", - $this->prefix . $table, - implode(", ", $changes), - $where); + return [implode(' ' . $glue . ' ', $cond), $bindings]; + } - $query = $this->query($sql); + /** @param list|string $fields */ + private function compileFields(array|string $fields): array + { + $fields = is_array($fields) ? $fields : array_map('trim', explode(',', $fields)); + $compiled = []; + + foreach ($fields as $alias => $field) { + if (!is_string($field) && !$field instanceof Expr) { + throw new InvalidQueryException('Invalid SELECT field'); + } + + $rendered = $this->compileFieldToken($field); + if (!is_int($alias)) { + $rendered .= ' AS ' . $this->quoteSingleIdentifier($this->assertIdentifier((string) $alias)); + } + $compiled[] = $rendered; + } + + if ($compiled === []) { + throw new InvalidQueryException('At least one field is required'); + } + + return $compiled; + } + + private function compileFieldToken(string|Expr $field): string + { + if ($field instanceof Expr) { + $sql = trim($field->toSql()); + if ($sql === '') { + throw new InvalidQueryException('Invalid SELECT expression'); + } + return $sql; + } + + $field = trim($field); + + if ($field === '*') { + return '*'; + } + + if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*\.\*$/', $field) === 1) { + [$table] = explode('.', $field, 2); + return $this->quoteIdentifier($table) . '.*'; + } + + if (preg_match('/^(COUNT|SUM|AVG|MIN|MAX)\((\*|[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\)$/i', $field, $matches) === 1) { + $function = strtoupper($matches[1]); + $target = $matches[2] === '*' ? '*' : $this->quoteIdentifierPath($this->assertIdentifier($matches[2])); + return sprintf('%s(%s)', $function, $target); + } + + if (preg_match( + '/^GROUP_CONCAT\(\s*(DISTINCT\s+)?([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\s*\)$/i', + $field, + $matches + ) === 1) { + $distinct = isset($matches[1]) && trim($matches[1]) !== '' ? 'DISTINCT ' : ''; + $target = $this->quoteIdentifierPath($this->assertIdentifier($matches[2])); + return sprintf('GROUP_CONCAT(%s%s)', $distinct, $target); + } + + if (preg_match( + '/^COUNT\(\s*CASE\s+WHEN\s+([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\s+IS\s+(NOT\s+)?NULL\s+THEN\s+(-?\d+)(?:\s+ELSE\s+(-?\d+))?\s+END\s*\)$/i', + $field, + $matches + ) === 1) { + $target = $this->quoteIdentifierPath($this->assertIdentifier($matches[1])); + $not = isset($matches[2]) && trim($matches[2]) !== '' ? 'NOT ' : ''; + $thenValue = $matches[3]; + $elseClause = isset($matches[4]) && $matches[4] !== '' ? ' ELSE ' . $matches[4] : ''; + + return sprintf( + 'COUNT(CASE WHEN %s IS %sNULL THEN %s%s END)', + $target, + $not, + $thenValue, + $elseClause + ); + } + + if (preg_match('/^COALESCE\((.+)\)$/i', $field, $matches) === 1) { + $arguments = array_map('trim', explode(',', $matches[1])); + if (count($arguments) < 2) { + throw new InvalidQueryException('COALESCE expects at least two identifiers'); + } + + $quoted = []; + foreach ($arguments as $argument) { + if ($argument === '') { + throw new InvalidQueryException('COALESCE expects valid identifier arguments'); + } + $quoted[] = $this->quoteIdentifierPath($this->assertIdentifier($argument)); + } + + return sprintf('COALESCE(%s)', implode(', ', $quoted)); + } - return $query->exec($bindings); + return $this->quoteIdentifierPath($this->assertIdentifier($field)); } /** - * Performs a DELETE query, returns number of affected rows + * Join formats: + * - Legacy map: ['left:table alias' => 'alias.id = base.ref']. + * Supported prefixes: inner:, left:, right:, full:, cross: + * If no prefix is provided, plain JOIN is used (database default join behavior). + * - Structured list: + * [ + * ['type' => 'left', 'table' => 'people', 'alias' => 'p', 'on' => 'p.id = t.person_id'], + * ['type' => 'cross', 'table' => 'calendar', 'alias' => 'c'], + * ] * - * @param string $table table name - * @param mixed $where condition - * @return int Number of affected rows - * @throws \Exception + * @param array $join + * @return array{0:string,1:array} */ - function delete($table, $where) + private function compileJoin(array $join, string $baseAlias): array { + if ($join === []) { + return ['', []]; + } - list($where, $bindings) = $this->_where($where); + $parts = []; - $sql = sprintf(/** @lang text */ - "DELETE FROM `%s` WHERE %s", $this->prefix . $table, $where); + foreach ($join as $key => $value) { + if (is_int($key)) { + if (!is_array($value)) { + throw new InvalidQueryException('Raw JOIN strings are disabled. Use structured join arrays.'); + } - $query = $this->query($sql); + $parts[] = $this->compileStructuredJoin($value); + continue; + } + + [$joinType, $tableDef] = $this->extractLegacyJoinType((string) $key); + [$table, $alias] = $this->parseTableAlias(trim($tableDef)); + $condition = trim((string) $value); + if ($condition === '' && $joinType !== 'CROSS JOIN') { + throw new InvalidQueryException('Join condition cannot be empty'); + } + + $parts[] = $this->renderJoinClause($joinType, $table, $alias, $condition); + } + + unset($baseAlias); + return [implode(' ', $parts), []]; + } + + /** @param array $join */ + private function compileStructuredJoin(array $join): string + { + if (!isset($join['table']) || !is_string($join['table']) || trim($join['table']) === '') { + throw new InvalidQueryException('Structured join requires a non-empty table'); + } + + $joinType = $this->normalizeJoinType($join['type'] ?? 'inner'); + $table = $this->assertIdentifier(trim($join['table'])); + $alias = isset($join['alias']) && is_string($join['alias']) && $join['alias'] !== '' + ? $this->assertIdentifier(trim($join['alias'])) + : $table; - return $query->exec($bindings); + $condition = isset($join['on']) ? trim((string) $join['on']) : ''; + if ($joinType !== 'CROSS JOIN' && $condition === '') { + throw new InvalidQueryException('Join condition cannot be empty'); + } + + return $this->renderJoinClause($joinType, $table, $alias, $condition); } - private function _where($args = null, $glue = "AND") + /** @return array{0:string,1:string} */ + private function extractLegacyJoinType(string $tableDef): array { + $tableDef = trim($tableDef); + if ($tableDef === '') { + throw new InvalidQueryException('Invalid table definition'); + } + + if (!str_contains($tableDef, ':')) { + return ['JOIN', $tableDef]; + } - $bindings = null; + [$type, $remainder] = explode(':', $tableDef, 2); + $type = strtolower(trim($type)); + $remainder = trim($remainder); + + if ($remainder === '') { + throw new InvalidQueryException('Invalid table definition'); + } + + if (!in_array($type, ['inner', 'left', 'right', 'full', 'cross'], true)) { + return ['JOIN', $tableDef]; + } - if ($args && is_array($args)) { - $cond = array(); - $bindings = array(); - $me = $this; + return [$this->normalizeJoinType($type), $remainder]; + } + + private function normalizeJoinType(mixed $type): string + { + if (!is_string($type) || trim($type) === '') { + throw new InvalidQueryException('Invalid join type'); + } + + $normalized = strtolower(trim($type)); + return match ($normalized) { + 'inner', 'inner join' => 'INNER JOIN', + 'join' => 'JOIN', + 'left', 'left join', 'left outer', 'left outer join' => 'LEFT JOIN', + 'right', 'right join', 'right outer', 'right outer join' => 'RIGHT JOIN', + 'full', 'full join', 'full outer', 'full outer join' => 'FULL OUTER JOIN', + 'cross', 'cross join' => 'CROSS JOIN', + default => throw new InvalidQueryException('Unsupported join type'), + }; + } + + private function renderJoinClause(string $joinType, string $table, string $alias, string $condition): string + { + $base = sprintf( + '%s %s %s', + $joinType, + $this->quoteIdentifier($this->prefix . $table), + $this->quoteIdentifier($alias) + ); - // TODO suportar _and, _or - foreach ($args as $key => $value) { + if ($joinType === 'CROSS JOIN') { + return $base; + } - // TODO if is_numeric($key) - $table = str_replace('`', '``', $key); - $table = implode('`.`', explode(".", $table)); - $key = crc32($key); + return $base . ' ON ' . $condition; + } - if (is_array($value)) { - // TODO quote array values - $cond[] = sprintf("`%s` IN (%s)", $table, implode(",", array_map(array($this, 'escape'), $value))); - } else { - $cond[] = sprintf("`%s` %s :where_%s", - $table, - is_null($value) ? 'is' : (strpos($value, '%') !== FALSE ? 'LIKE' : '='), - $key); - $bindings[":where_$key"] = $value; + private function compileGroup(mixed $group): string + { + if (is_string($group)) { + $parts = array_values(array_filter(array_map('trim', explode(',', $group)), fn (string $part): bool => $part !== '')); + } elseif (is_array($group)) { + $parts = []; + foreach ($group as $part) { + if (!is_string($part) || trim($part) === '') { + throw new InvalidQueryException('Invalid group value'); } + + $parts[] = trim($part); } + } else { + throw new InvalidQueryException('Invalid group value'); + } - $args = implode(" $glue ", $cond); + if ($parts === []) { + throw new InvalidQueryException('Invalid group value'); } - return array($args, $bindings); + $compiled = array_map( + fn (string $part): string => $this->quoteIdentifierPath($this->assertIdentifier($part)), + $parts + ); + + return implode(', ', $compiled); } - /** DB Functions */ + private function compileLockClause(mixed $lock): string + { + if ($lock === null || $lock === false || $lock === '') { + return ''; + } - /** - * Ativa debugging no db (grava queries, etc) - * @param bool|true $status - */ - function debug($status = array()) + if ($lock === true) { + return ' FOR UPDATE'; + } + + if (!is_string($lock)) { + throw new InvalidQueryException('Invalid lock clause'); + } + + $normalized = strtolower(trim($lock)); + return match ($normalized) { + 'update', 'for update' => ' FOR UPDATE', + 'share', 'for share' => ' FOR SHARE', + default => throw new InvalidQueryException('Unsupported lock mode'), + }; + } + + private function compileOrder(mixed $order): string { - $this->debug = $status; + if (is_string($order)) { + $pieces = array_map('trim', explode(',', $order)); + $rendered = []; + foreach ($pieces as $piece) { + if ($piece === '') { + continue; + } + $rendered[] = $this->compileOrderPiece($piece); + } + + if ($rendered === []) { + throw new InvalidQueryException('Invalid order clause'); + } + + return implode(', ', $rendered); + } + + if (is_array($order)) { + if ($order === []) { + throw new InvalidQueryException('Invalid order clause'); + } + + if ( + count($order) === 2 + && isset($order[0], $order[1]) + && is_string($order[0]) + && is_string($order[1]) + && in_array(strtoupper(trim($order[1])), ['ASC', 'DESC'], true) + ) { + return $this->compileOrderPiece($order[0] . ' ' . $order[1]); + } + + $rendered = []; + foreach ($order as $piece) { + if (is_array($piece)) { + if ( + !isset($piece[0]) + || !is_string($piece[0]) + || (isset($piece[1]) && !is_string($piece[1])) + ) { + throw new InvalidQueryException('Invalid order clause'); + } + + $rendered[] = $this->compileOrderPiece( + isset($piece[1]) ? $piece[0] . ' ' . $piece[1] : $piece[0] + ); + continue; + } + + $rendered[] = $this->compileOrderPiece((string) $piece); + } + + return implode(', ', $rendered); + } + + throw new InvalidQueryException('Invalid order clause'); } - /** - * Returns a DB\Table helper for this table - * @param $table String the table name - * @param array $params Optional Primary Key, defaults to 'id' - * @return DB\Table - */ - function table($table, array $params = ['pk' => 'id']) + private function compileOrderPiece(string $piece): string { - if (class_exists($table) && is_subclass_of($table, 'Objectiveweb\DB\Table')) { + $trimmed = trim($piece); + + if (preg_match( + '/^([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)(?:\s+IS\s+(NOT\s+)?NULL)?(?:\s+(ASC|DESC))?$/i', + $trimmed, + $matches + ) !== 1) { + throw new InvalidQueryException('Invalid order expression'); + } - return new $table($this); - } else { - return new DB\Table($this, $table, $params); + $field = $this->quoteIdentifierPath($this->assertIdentifier($matches[1])); + $nullClause = ''; + if (stripos($trimmed, ' IS ') !== false) { + $nullClause = isset($matches[2]) && trim((string) $matches[2]) !== '' ? ' IS NOT NULL' : ' IS NULL'; } + + $dir = isset($matches[3]) ? ' ' . strtoupper($matches[3]) : ''; + + return $field . $nullClause . $dir; } - function escape($string) + /** @return array{0:string,1:string} */ + private function parseTableAlias(string $tableDef): array { - return $this->pdo->quote($string); + $parts = preg_split('/\s+/', trim($tableDef)); + if (!is_array($parts) || $parts === []) { + throw new InvalidQueryException('Invalid table definition'); + } + + $table = $this->assertIdentifier($parts[0]); + $alias = isset($parts[1]) ? $this->assertIdentifier($parts[1]) : $table; + + return [$table, $alias]; } - public static function array_cleanup(array $array, array $valid_keys = [], $defaults = []) + private function assertIdentifier(string $identifier): string { - if (!is_array($valid_keys)) { - $valid_keys = array($valid_keys); + if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/', $identifier) !== 1) { + throw new InvalidQueryException("Invalid identifier: {$identifier}"); } - $clean_array = array_intersect_key($array, array_flip($valid_keys)); - return array_merge($defaults, $clean_array); + + return $identifier; } - public static function now() + private function quoteIdentifierPath(string $identifier): string { - return date('Y-m-d H:i:s'); + $segments = explode('.', $identifier); + $segments = array_map(fn (string $segment): string => $this->quoteIdentifier($segment), $segments); + return implode('.', $segments); + } + + private function quoteIdentifier(string $identifier): string + { + return $this->connection->quoteIdentifier($identifier); + } + + private function quoteSingleIdentifier(string $identifier): string + { + return $this->connection->quoteSingleIdentifier($identifier); + } + + /** + * @param array $dsn + * @param array $options + * @return array + */ + private static function fromParsedUrl(array $dsn, ?string $username, string $password, array $options): array + { + $scheme = (string) ($dsn['scheme'] ?? 'mysql'); + $dbName = isset($dsn['dbname']) ? (string) $dsn['dbname'] : ltrim((string) ($dsn['path'] ?? ''), '/'); + + $params = [ + 'driver' => self::mapDriver($scheme), + 'host' => $dsn['host'] ?? '127.0.0.1', + 'dbname' => $dbName, + 'user' => $dsn['user'] ?? $username, + 'password' => $dsn['pass'] ?? $password, + ]; + + if (isset($dsn['port'])) { + $params['port'] = (int) $dsn['port']; + } + + return array_merge($params, $options); + } + + /** + * @param array $options + * @return array + */ + private static function fromDsnString(string $dsn, ?string $username, string $password, array $options): array + { + if (strpos($dsn, ':') === false) { + throw new InvalidQueryException('Invalid DSN format'); + } + + [$scheme, $rest] = explode(':', $dsn, 2); + + if ($scheme === 'sqlite') { + $path = trim($rest); + if (str_starts_with($path, 'dbname=')) { + $path = substr($path, strlen('dbname=')); + } + + $params = [ + 'driver' => 'pdo_sqlite', + 'path' => $path, + 'user' => $username, + 'password' => $password, + ]; + + return array_merge($params, $options); + } + + $pairs = []; + foreach (explode(';', $rest) as $chunk) { + if ($chunk === '' || strpos($chunk, '=') === false) { + continue; + } + [$key, $value] = explode('=', $chunk, 2); + $pairs[trim($key)] = trim($value); + } + + $params = [ + 'driver' => self::mapDriver($scheme), + 'host' => $pairs['host'] ?? '127.0.0.1', + 'dbname' => $pairs['dbname'] ?? '', + 'charset' => $pairs['charset'] ?? 'utf8', + 'user' => $username, + 'password' => $password, + ]; + + return array_merge($params, $options); + } + + private static function mapDriver(string $scheme): string + { + return match ($scheme) { + 'mysql' => 'pdo_mysql', + 'pgsql' => 'pdo_pgsql', + 'sqlite' => 'pdo_sqlite', + 'sqlsrv' => 'pdo_sqlsrv', + default => throw new InvalidQueryException("Unsupported driver scheme: {$scheme}"), + }; } } diff --git a/src/DB/Collection.php b/src/DB/Collection.php index d648d06..9e3db6f 100644 --- a/src/DB/Collection.php +++ b/src/DB/Collection.php @@ -1,85 +1,92 @@ */ + private array $data; + private int $startIndex; + private int $endIndex; + private int $total; + + /** @param list $data */ + public function __construct(array $data, int $startIndex = 0, ?int $endIndex = null, ?int $total = null) { $this->data = $data; - $this->startIndex = $startIndex; - $this->endIndex = $endIndex === null ? count($data) - 1 : $endIndex; - $this->total = $total === null ? count($data) : $total; - + $this->endIndex = $endIndex ?? (count($data) - 1); + $this->total = $total ?? count($data); } - function data($key = null) + /** @return list|mixed */ + public function data(?int $key = null): mixed { if ($key !== null) { return $this->data[$key]; - } else { - return $this->data; } + + return $this->data; } - public function total() + public function total(): int { return $this->total; } - function render($content_type = "") + public function contentRange(): string { - switch ($content_type) { - default: - header(sprintf("Content-Range: items %d-%d/%d", $this->startIndex, $this->endIndex, $this->total)); - return json_encode($this->data); - } + return sprintf('items %d-%d/%d', $this->startIndex, $this->endIndex, $this->total); } - public function jsonSerialize() + public function render(string $contentType = 'application/json'): string + { + unset($contentType); + return json_encode($this->data, JSON_THROW_ON_ERROR); + } + + /** @return list */ + public function jsonSerialize(): array { return $this->data; } - public function offsetExists($offset) + public function offsetExists(mixed $offset): bool { - return array_key_exists($offset, $this->data); + return array_key_exists((int) $offset, $this->data); } - public function offsetGet($offset) + public function &offsetGet(mixed $offset): mixed { - return $this->data[$offset]; + return $this->data[(int) $offset]; } - public function offsetSet($offset, $value) + public function offsetSet(mixed $offset, mixed $value): void { - $this->data[$offset] = $value; + if ($offset === null) { + $this->data[] = $value; + return; + } + + $this->data[(int) $offset] = $value; } - public function offsetUnset($offset) + public function offsetUnset(mixed $offset): void { - unset($this->data[$offset]); + unset($this->data[(int) $offset]); } - public function count() + public function count(): int { return count($this->data); } - public function getIterator() + public function &getIterator(): \Traversable { - $dataIterator = function () { - foreach($this->data as $key => $val) { - yield $key => $val; - } - }; - - return $dataIterator(); + foreach ($this->data as $key => &$val) { + yield $key => $val; + } } } diff --git a/src/DB/CrudTrait.php b/src/DB/CrudTrait.php deleted file mode 100644 index 2146011..0000000 --- a/src/DB/CrudTrait.php +++ /dev/null @@ -1,187 +0,0 @@ -crudSetup($db, $table, $pk) on its constructor - */ -trait CrudTrait -{ - /** @var \Objectiveweb\DB */ - protected $db; - - /** @var String */ - protected $table = null; - - protected $params = null; - - protected function crudSetup(DB $db, $table, $params = []) - { - $this->db = $db; - - if (!$this->table) { - $this->table = $table; - } - - if (!$this->params) { - $this->params = array_merge([ - 'pk' => 'id', - 'join' => [] - ], $params); - } - - } - - /** - * index($query = array()) - * Returns a list of rows matching $query. - * - * @param array $params - * You can define - * $params[range] range of records to return - * $params[sort] the results sort order - * @param array $filter Default filter that is merged with params, overrides any user-provided values - * @return array - * @throws \Exception - */ - public function index($params = [], $filter = []) - { - // Where clause - if (!empty($params['filter'])) { - if (!is_array($params['filter'])) { - $where = array_merge(json_decode($params['filter'], true), $filter); - } else { - $where = array_merge($params['filter'], $filter); - } - } else { - $where = $filter; - } - - // Other query parameters - $queryparams = []; - if (!empty($params['fields'])) { - if (!is_array($params['fields'])) { - $queryparams['fields'] = explode(',', $params['fields']); - } - - $queryparams['fields'][0] = 'SQL_CALC_FOUND_ROWS ' . $params['fields'][0]; - - } else { - $queryparams['fields'] = "SQL_CALC_FOUND_ROWS $this->table.*"; - } - - if (!empty($params['sort'])) { - if (!is_array($params['sort'])) { - $sort = json_decode($params['sort']); - } else { - $sort = $params['sort']; - } - - $queryparams['order'] = implode(" ", $sort); - } - - if (!empty($params['range'])) { - if (!is_array($params['range'])) { - $params['range'] = json_decode($params['range']); - } - $queryparams['offset'] = $params['range'][0]; - $queryparams['limit'] = $params['range'][1] - $params['range'][0] + 1; - } else { - $queryparams['offset'] = 0; - } - - $queryparams['join'] = isset($params['join']) ? $params['join'] : $this->params['join']; - - $query = $this->db->select($this->table, $where, $queryparams); - - $rows_query = $this->db->query('SELECT FOUND_ROWS() as count'); - $rows_query->exec(); - - $rows = $rows_query->fetch(); - - if (!$rows) { - throw new \Exception('Error while FOUND_ROWS()', 500); - } - - $data = $query->all(); - $rowscount = intval($rows['count']); - - return new Collection($data, $queryparams['offset'], $queryparams['offset'] + count($data) - 1, $rowscount); - } - - /** - * Retrieves records from table - * - * get() - * get($query = array()) - * @param mixed $key - * @param array $params - * @return mixed - * @see index($query = array()) - * get($key, $params = array()) - * Returns row with key $key, with optional select $params - * - */ - public function get($key = null, $params = []) - { - - if (empty($key) || is_array($key)) { - return $this->index($key); - } - - $params['join'] = $this->params['join']; - - // get single - $key = sprintf('`%s` = %s', $this->params['pk'], $this->db->escape($key)); - $query = $this->db->select($this->table, $key, $params); - - if (!$rsrc = $query->fetch()) { - throw new \Exception('Record not found', 404); - } - - return $rsrc; - } - - public function post($data) - { - $id = $this->db->insert($this->table, $data); - - return $id ? [$this->params['pk'] => $id] : null; - } - - public function put($key, $data) - { - if (!is_array($key)) { - $key = array($this->params['pk'] => $key); - } - - return array('updated' => $this->db->update($this->table, $data, $key)); - } - - public function delete($key) - { - if (!is_array($key)) { - $key = array($this->params['pk'] => $key); - } - - // TODO support cascade delete com joins? - - return $this->db->delete($this->table, $key); - } - - public function findBy($key, $value) - { - return $this->index([ - 'filter' => [ - $key => $value - ] - ]); - } -} diff --git a/src/DB/Exception/DBException.php b/src/DB/Exception/DBException.php new file mode 100644 index 0000000..c724174 --- /dev/null +++ b/src/DB/Exception/DBException.php @@ -0,0 +1,9 @@ +sql; + } +} diff --git a/src/DB/Model.php b/src/DB/Model.php new file mode 100644 index 0000000..a4b5c86 --- /dev/null +++ b/src/DB/Model.php @@ -0,0 +1,265 @@ + */ + protected static array $validFields = []; + + /** @var array> */ + protected static array $creationRules = []; + + /** @var array> */ + protected static array $updateRules = []; + + /** @var array */ + protected array $attributes = []; + + /** @param array $data */ + public function __construct(array $data = []) + { + $this->fill($data); + } + + /** @param array $data @return array */ + public static function normalizeForCreate(array $data): array + { + $filtered = static::filterValidFields($data); + $filtered = static::applyRules($filtered, static::$creationRules, true); + + if ($filtered === []) { + throw new ModelValidationException(static::class . ' has no valid fields for creation'); + } + + return $filtered; + } + + /** @param array $data @return array */ + public static function normalizeForUpdate(array $data): array + { + $filtered = static::filterValidFields($data); + $rules = static::$updateRules !== [] ? static::$updateRules : static::$creationRules; + $filtered = static::applyRules($filtered, $rules, false); + + if ($filtered === []) { + throw new ModelValidationException(static::class . ' has no valid fields for update'); + } + + return $filtered; + } + + /** @param array $data */ + public function fill(array $data): self + { + foreach (static::filterValidFields($data) as $key => $value) { + $this->attributes[$key] = $value; + + if (!property_exists($this, $key)) { + continue; + } + + $property = new \ReflectionProperty($this, $key); + if (!$property->isPublic()) { + continue; + } + + try { + $this->{$key} = $value; + } catch (\TypeError) { + // Keep raw value in attributes even when public typed property rejects it. + } + } + + return $this; + } + + /** @return array */ + public function toArray(): array + { + return $this->attributes; + } + + public function jsonSerialize(): array + { + return $this->attributes; + } + + public function __get(string $name): mixed + { + return $this->attributes[$name] ?? null; + } + + public function __set(string $name, mixed $value): void + { + $this->attributes[$name] = $value; + } + + /** @param array $data @return array */ + protected static function filterValidFields(array $data): array + { + $validFields = static::$validFields; + if ($validFields === []) { + return $data; + } + + return array_intersect_key($data, array_flip($validFields)); + } + + /** + * @param array $data + * @param array> $rules + * @return array + */ + protected static function applyRules(array $data, array $rules, bool $isCreate): array + { + $normalized = $data; + + foreach ($rules as $field => $fieldRules) { + $exists = array_key_exists($field, $normalized); + + if (!$exists) { + if ($isCreate && (($fieldRules['required'] ?? false) === true)) { + throw new ModelValidationException("{$field} is required"); + } + + continue; + } + + $value = $normalized[$field]; + + if ($value === null) { + $nullable = ($fieldRules['nullable'] ?? false) === true; + if (!$nullable && (($fieldRules['required'] ?? false) === true)) { + throw new ModelValidationException("{$field} cannot be null"); + } + + continue; + } + + if (isset($fieldRules['filter'])) { + $value = static::applyPhpFilter($field, $value, $fieldRules); + } + + if (isset($fieldRules['validate'])) { + static::runCustomValidator($field, $value, $normalized, $fieldRules['validate'], $isCreate); + } + + if (isset($fieldRules['type']) && !static::isOfType($value, (string) $fieldRules['type'])) { + throw new ModelValidationException("{$field} must be of type {$fieldRules['type']}"); + } + + if (isset($fieldRules['enum']) && is_array($fieldRules['enum']) && !in_array($value, $fieldRules['enum'], true)) { + throw new ModelValidationException("{$field} has an invalid value"); + } + + if (isset($fieldRules['pattern']) && is_string($fieldRules['pattern']) && is_string($value)) { + if (preg_match($fieldRules['pattern'], $value) !== 1) { + throw new ModelValidationException("{$field} format is invalid"); + } + } + + if (isset($fieldRules['min'])) { + static::assertMin($field, $value, $fieldRules['min']); + } + + if (isset($fieldRules['max'])) { + static::assertMax($field, $value, $fieldRules['max']); + } + + $normalized[$field] = $value; + } + + return $normalized; + } + + private static function isOfType(mixed $value, string $type): bool + { + return match ($type) { + 'int', 'integer' => is_int($value), + 'float', 'double' => is_float($value), + 'numeric' => is_numeric($value), + 'string' => is_string($value), + 'bool', 'boolean' => is_bool($value), + 'array' => is_array($value), + default => true, + }; + } + + private static function assertMin(string $field, mixed $value, mixed $min): void + { + if (is_numeric($value) && $value < $min) { + throw new ModelValidationException("{$field} must be >= {$min}"); + } + + if (is_string($value) && strlen($value) < (int) $min) { + throw new ModelValidationException("{$field} length must be >= {$min}"); + } + } + + private static function assertMax(string $field, mixed $value, mixed $max): void + { + if (is_numeric($value) && $value > $max) { + throw new ModelValidationException("{$field} must be <= {$max}"); + } + + if (is_string($value) && strlen($value) > (int) $max) { + throw new ModelValidationException("{$field} length must be <= {$max}"); + } + } + + /** @param array $fieldRules */ + private static function applyPhpFilter(string $field, mixed $value, array $fieldRules): mixed + { + $filter = $fieldRules['filter']; + if (!is_int($filter)) { + throw new ModelValidationException("{$field} has an invalid filter definition"); + } + + $options = []; + + if (array_key_exists('filter_options', $fieldRules)) { + $options['options'] = $fieldRules['filter_options']; + } + + if (array_key_exists('filter_flags', $fieldRules)) { + $options['flags'] = $fieldRules['filter_flags']; + } + + $flags = (int) ($options['flags'] ?? 0); + $options['flags'] = $flags | FILTER_NULL_ON_FAILURE; + + $filtered = filter_var($value, $filter, $options); + if ($filtered === null || $filtered === false) { + throw new ModelValidationException("{$field} failed filter validation"); + } + + return $filtered; + } + + /** @param array $data */ + private static function runCustomValidator( + string $field, + mixed $value, + array $data, + mixed $validator, + bool $isCreate + ): void { + if (!is_callable($validator)) { + throw new ModelValidationException("{$field} has a non-callable validator"); + } + + $result = $validator($value, $field, $data, $isCreate); + if ($result === false) { + throw new ModelValidationException("{$field} failed custom validation"); + } + + if (is_string($result) && $result !== '') { + throw new ModelValidationException($result); + } + } +} diff --git a/src/DB/Query.php b/src/DB/Query.php index 0a34bfb..3158fe7 100644 --- a/src/DB/Query.php +++ b/src/DB/Query.php @@ -1,110 +1,121 @@ */ + private array $bindings = []; - function __construct($stmt) { - $this->error = null; - $this->stmt = $stmt; - } + private ?Result $result = null; - /** - * - * Binds $value to $pos - * - * from http://stackoverflow.com/a/6743773/164469 - * - * @param $pos string "field" - * @param $value mixed [value] - * @param null $type PDO::PARAM_* code - * @return $this - */ - public function bind($pos, $value, $type = null) - { - if (is_null($type)) { - switch (true) { - case is_int($value): - $type = PDO::PARAM_INT; - break; - case is_bool($value): - $type = PDO::PARAM_BOOL; - break; - case is_null($value): - $type = PDO::PARAM_NULL; - break; - default: - $type = PDO::PARAM_STR; - } - } + public ?string $debugSql = null; - $this->stmt->bindValue(":$pos", $value, $type); + public function __construct(Connection $connection, string $sql) + { + $this->connection = $connection; + $this->sql = $sql; + } + public function bind(string $pos, mixed $value): self + { + $this->bindings[ltrim($pos, ':')] = $value; return $this; } /** - * Executes the current statement, returns the number of modified rows - * - * @param array $bindings [ ":field" => "value", ... ] - * @throws \PDOException - * @throws \Exception when an error occurs + * @param array|null $bindings */ - function exec($bindings = null) + public function exec(?array $bindings = null): int { - $res = $this->stmt->execute($bindings); + $params = $this->normalizeBindings($bindings); + $this->freeResult(); - if ($res !== false) { - return $this->stmt->rowCount(); - } else { - throw new \Exception(json_encode($this->stmt->errorInfo()), 500); + if ($this->isResultSetQuery($this->sql)) { + $this->result = $this->connection->executeQuery($this->sql, $params); + return $this->result->rowCount(); } + + return $this->connection->executeStatement($this->sql, $params); } - /** - * Fetches a row from a result set associated with the current Statement. - * - * @return array - */ - function fetch() + /** @return array|false */ + public function fetch(): array|false { - return $this->stmt->fetch(PDO::FETCH_ASSOC); + if ($this->result === null) { + return false; + } + + $row = $this->result->fetchAssociative(); + return $row === false ? false : $row; } - /** - * Returns an array containing all of the result set rows - * - * @return array - */ - function all() + /** @return list> */ + public function all(): array { - return $this->stmt->fetchAll(PDO::FETCH_ASSOC); + if ($this->result === null) { + return []; + } + + return $this->result->fetchAllAssociative(); } + /** @return array> */ + public function map(string $field): array + { + if ($this->result === null) { + return []; + } + + $mapped = []; + + while (($row = $this->result->fetchAssociative()) !== false) { + if (!array_key_exists($field, $row)) { + throw new InvalidQueryException("Invalid field {$field}"); + } + + $mapped[$row[$field]] = $row; + } + + return $mapped; + } /** - * Returns an associative array with all the result set rows mapped by $field - * @param string $field the field to index + * @param array|null $bindings + * @return array */ - function map($field) { - $map = array(); + private function normalizeBindings(?array $bindings): array + { + $params = $this->bindings; - while($row = $this->stmt->fetch(PDO::FETCH_ASSOC)) { - if(!isset($row[$field])) { - throw new \Exception("Invalid field $field", 500); + if ($bindings !== null) { + foreach ($bindings as $key => $value) { + $params[ltrim((string) $key, ':')] = $value; } - - $map[$row[$field]] = $row; } - return $map; + return $params; } + private function isResultSetQuery(string $sql): bool + { + return (bool) preg_match('/^\s*(SELECT|SHOW|DESCRIBE|PRAGMA|WITH)\b/i', $sql); + } -} \ No newline at end of file + private function freeResult(): void + { + if ($this->result !== null) { + $this->result->free(); + $this->result = null; + } + } +} diff --git a/src/DB/Table.php b/src/DB/Table.php index 1ac635c..68c0cbf 100644 --- a/src/DB/Table.php +++ b/src/DB/Table.php @@ -1,23 +1,219 @@ |null */ + protected ?array $params = null; + + /** @param array $params */ + public function __construct(DB $db, ?string $table = null, array $params = []) + { + $this->db = $db; + + if ($this->table === null) { + $this->table = $table; + } + + if ($this->params === null) { + $this->params = array_merge([ + 'pk' => 'id', + 'join' => [], + 'group' => null, + 'fields' => ['*'], + 'model' => null, + ], $params); + } + + if($this->params['model']) { + if (!is_subclass_of($this->params['model'], Model::class)) { + throw new InvalidQueryException("Invalid model class: {$this->params['model']}"); + } + + $this->modelClass = new \ReflectionClass($this->params['model']); + } + } + + /** @param array $params @return array */ + private function parseParams(array $params): array + { + $queryParams = []; + + if (empty($params['fields'])) { + $params['fields'] = $this->params['fields']; + } + + if (!is_array($params['fields'])) { + $params['fields'] = array_map('trim', explode(',', (string) $params['fields'])); + } + + $queryParams['fields'] = $params['fields']; + + if (!empty($params['sort'])) { + $queryParams['order'] = $params['sort']; + } + + if (!empty($params['range'])) { + if (!is_array($params['range'])) { + $params['range'] = (array) json_decode((string) $params['range'], true); + } + $queryParams['offset'] = (int) $params['range'][0]; + $queryParams['limit'] = (int) $params['range'][1] - (int) $params['range'][0] + 1; + } else { + $queryParams['offset'] = 0; + } - /** - * Table is a controller for a DB table - * Usually instantiated via $db->table('tablename' [, [ 'pk' => 'id'] ]); - * - * @param \Objectiveweb\DB $db DB instance - * @param string $table table name - * @param string $params Optional defaults to [ 'pk' => 'id', 'join' => [] ] - */ - public function __construct(DB $db, $table = null, $params = []) + $queryParams['join'] = $params['join'] ?? $this->params['join']; + $queryParams['group'] = $params['group'] ?? $this->params['group']; + + return $queryParams; + } + + /** @param array $filter @param array $params */ + public function select(array $filter = [], array $params = []): Collection + { + if (!empty($params['filter'])) { + if (!is_array($params['filter'])) { + $decoded = json_decode((string) $params['filter'], true); + $where = array_merge($filter, is_array($decoded) ? $decoded : []); + } else { + $where = array_merge($filter, $params['filter']); + } + } else { + $where = $filter; + } + + $queryParams = $this->parseParams($params); + $query = $this->db->select((string) $this->table, $where, $queryParams); + + $countParams = [ + 'join' => $queryParams['join'] ?? [], + 'group' => $queryParams['group'] ?? null, + ]; + + $rowsCount = $this->db->count((string) $this->table, $where, $countParams); + $data = $this->hydrateRows($query->all()); + + return new Collection( + $data, + (int) ($queryParams['offset'] ?? 0), + (int) ($queryParams['offset'] ?? 0) + count($data) - 1, + $rowsCount + ); + } + + /** @param mixed $key @param array $params @return Collection|array|object */ + public function get(mixed $key = null, array $params = []): mixed { - $this->crudSetup($db, $table, $params); + if ($key === null || is_array($key)) { + return $this->select(is_array($key) ? $key : [], $params); + } + + $params = $this->parseParams($params); + $where = ["{$this->table}.{$this->params['pk']}" => $key]; + $query = $this->db->select((string) $this->table, $where, array_merge($params, ['limit' => 1])); + + $record = $query->fetch(); + if ($record === false) { + throw new NotFoundException('Record not found', 404); + } + + return $this->hydrateRow($record); } + + /** @param array|Model $data */ + public function insert(array|Model $data): ?array + { + $id = $this->db->insert((string) $this->table, $this->normalizeCreateData($data)); + + return $id ? [(string) $this->params['pk'] => $id] : null; + } + + /** @param int|string|array $key @param array|Model $data @return array */ + public function update(int|string|array $key, array|Model $data): array + { + if (!is_array($key)) { + $key = [(string) $this->params['pk'] => $key]; + } + + return ['updated' => $this->db->update((string) $this->table, $this->normalizeUpdateData($data), $key)]; + } + + /** @param int|string|array $key */ + public function delete(int|string|array $key): int + { + if (!is_array($key)) { + $key = [(string) $this->params['pk'] => $key]; + } + + return $this->db->delete((string) $this->table, $key); + } + + public function findBy(string $key, mixed $value): Collection + { + return $this->select([], [ + 'filter' => [ + $key => $value, + ], + ]); + } + + /** @param list> $rows @return list|object> */ + private function hydrateRows(array $rows): array + { + if (!$this->modelClass) { + return $rows; + } + + return array_map(fn (array $row): object => $this->modelClass->newInstance($row), $rows); + } + + /** @param array $row */ + private function hydrateRow(array $row): array|object + { + if (!$this->modelClass) { + return $row; + } + + return $this->modelClass->newInstance($row); + } + + /** @param array|Model $data @return array */ + private function normalizeCreateData(array|Model $data): array + { + $payload = $data instanceof Model ? $data->toArray() : $data; + + if (!$this->modelClass) { + return $payload; + } + + $modelClass = (string) $this->params['model']; + return $modelClass::normalizeForCreate($payload); + } + + /** @param array|Model $data @return array */ + private function normalizeUpdateData(array|Model $data): array + { + $payload = $data instanceof Model ? $data->toArray() : $data; + + if (!$this->modelClass) { + return $payload; + } + + $modelClass = (string) $this->params['model']; + return $modelClass::normalizeForUpdate($payload); + } + } diff --git a/test/CrudTest.php b/test/CrudTest.php index 723a704..e34c9f1 100644 --- a/test/CrudTest.php +++ b/test/CrudTest.php @@ -1,62 +1,55 @@ ['name' => 'test'], - 2 => ['name' => 'test1'], - 3 => ['name' => 'test2'], - 4 => ['name' => 'test3'], - 5 => ['name' => null] + protected static Table $table; + + /** @var array> */ + protected static array $testData = [ + 1 => ['name' => 'test', 'f1' => 'test', 'f2' => 'test', 'f3' => 'test'], + 2 => ['name' => 'test1', 'f1' => 'test1', 'f2' => 'test1', 'f3' => 'test1'], + 3 => ['name' => 'test2', 'f1' => 'test2', 'f2' => 'test2', 'f3' => 'test2'], + 4 => ['name' => 'test3', 'f1' => 'test3', 'f2' => 'test3', 'f3' => 'test3'], + 5 => ['name' => null, 'f1' => 'test3', 'f2' => 'test3', 'f3' => 'test3'], ]; - public static function setUpBeforeClass() + public static function setUpBeforeClass(): void { - $db = DB::connect('mysql:dbname=objectiveweb;host=127.0.0.1', 'root', getenv('MYSQL_PASSWORD')); - $db->query('drop table if exists db_test')->exec(); - - $db->query('create table db_test - (`id` INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT, - `name` VARCHAR(255));')->exec(); - - self::$table = $db->table('db_test'); - + $db = DbTestBootstrap::connect(); + DbTestBootstrap::createDbTestTable($db, true); + static::$table = $db->table('db_test'); } - public function testInsert() + public function testInsert(): void { foreach (self::$testData as $k => $v) { - $id = self::$table->post($v); - $this->assertEquals($k, $id['id']); + $id = static::$table->insert($v); + $this->assertEquals($k, (int) $id['id']); } } - /** - * @depends testInsert - */ - public function testIndex() + /** @depends testInsert */ + public function testIndex(): void { - $rows = self::$table->index(); + $rows = static::$table->select(); $this->assertEquals(5, count($rows)); $this->assertEquals('test', $rows[0]['name']); - $rows = self::$table->get(); + $rows = static::$table->get(); $this->assertEquals(5, count($rows)); $this->assertEquals('test', $rows[0]['name']); $count = 0; - foreach ($rows as $key => $value) { + foreach ($rows as $value) { $this->assertEquals(self::$testData[$value['id']]['name'], $value['name']); $count++; } @@ -64,60 +57,93 @@ public function testIndex() $this->assertEquals(5, $count); } - /** - * @depends testInsert - */ - public function testPagination() + /** @depends testInsert */ + public function testCollectionSupportsByReferenceIteration(): void + { + $rows = static::$table->select([], ['sort' => ['id', 'asc']]); + + foreach ($rows as &$row) { + $row['name'] = 'mutated-' . ((string) $row['id']); + } + unset($row); + + $this->assertSame('mutated-1', $rows[0]['name']); + $this->assertSame('mutated-2', $rows[1]['name']); + } + + /** @depends testInsert */ + public function testFields(): void { - $data = self::$table->index(array('range' => [0, 1], 'sort' => ['id', 'asc'])); + $data = static::$table->select([], ['fields' => ['id', 'f1', 'f2']]); + + foreach ($data as $result) { + $this->assertCount(3, $result); + $this->assertEquals($result['f1'], self::$testData[$result['id']]['f1']); + $this->assertEquals($result['f2'], self::$testData[$result['id']]['f2']); + } + } + + /** @depends testInsert */ + public function testPagination(): void + { + $data = static::$table->select([], ['range' => [0, 1], 'sort' => ['id', 'asc']]); $this->assertEquals(2, count($data)); $this->assertEquals('test', $data[0]['name']); $this->assertEquals('test1', $data[1]['name']); $this->assertEquals(5, $data->total()); - $data = self::$table->index(array('range' => [2, 3], 'sort' => ['id', 'asc'])); + $data = static::$table->select([], ['range' => [2, 3], 'sort' => ['id', 'asc']]); $this->assertEquals(2, count($data)); $this->assertEquals('test2', $data[0]['name']); $this->assertEquals('test3', $data[1]['name']); $this->assertEquals(5, $data->total()); - $data = self::$table->index(array('range' => [4, 5], 'sort' => ['id', 'asc'])); + $data = static::$table->select([], ['range' => [4, 5], 'sort' => ['id', 'asc']]); $this->assertEquals(1, count($data)); $this->assertEquals(null, $data[0]['name']); $this->assertEquals(5, $data->total()); } - /** - * @depends testPagination - */ - public function testUpdate() + /** @depends testInsert */ + public function testMultipleSortFields(): void + { + $data = static::$table->select([], [ + 'sort' => [ + ['f3', 'desc'], + ['id', 'asc'], + ], + ]); + + $this->assertEquals(5, count($data)); + $this->assertEquals(4, (int) $data[0]['id']); + $this->assertEquals(5, (int) $data[1]['id']); + } + + /** @depends testPagination */ + public function testUpdate(): void { - $r = self::$table->put(array('name' => 'test1'), array('name' => 'test4')); + $r = static::$table->update(['name' => 'test1'], ['name' => 'test4']); $this->assertEquals(1, $r['updated']); } - /** - * @depends testUpdate - */ - public function testGetCollection() + /** @depends testUpdate */ + public function testGetCollection(): void { - $r = self::$table->get(['filter' => ['name' => 'test4']]); + $r = static::$table->get(['name' => 'test4']); $this->assertNotEmpty($r); $this->assertEquals('2', $r[0]['id']); $this->assertEquals('test4', $r[0]['name']); } - /** - * @depends testUpdate - */ - public function testGetParams() + /** @depends testUpdate */ + public function testGetParams(): void { - $r = self::$table->get(1, array('fields' => 'name')); + $r = static::$table->get(1, ['fields' => 'name']); $this->assertNotEmpty($r); @@ -125,38 +151,35 @@ public function testGetParams() $this->assertEquals('test', $r['name']); } - /** - * @depends testUpdate - */ - public function testUpdateKey() + /** @depends testUpdate */ + public function testUpdateKey(): void { - $r = self::$table->put(3, array('name' => 'test2.1')); + $r = static::$table->update(3, ['name' => 'test2.1']); $this->assertEquals(1, $r['updated']); } - /** - * @depends testUpdateKey - */ - public function testSelectKey() + /** @depends testUpdateKey */ + public function testSelectKey(): void { - $r = self::$table->get(3); + $r = static::$table->get(3); $this->assertEquals('test2.1', $r['name']); } - public function testDelete() + /** @depends testSelectKey */ + public function testDelete(): void { - $data = self::$table->index(); + $data = static::$table->select(); $this->assertEquals($data->total(), 5); - $r = self::$table->delete(array('name' => 'test1')); + $r = static::$table->delete(['name' => 'test1']); $this->assertEquals(0, $r); - $r = self::$table->delete(array('name' => 'test4')); + $r = static::$table->delete(['name' => 'test4']); $this->assertEquals(1, $r); - $data = self::$table->index(); + $data = static::$table->select(); $this->assertEquals($data->total(), 4); } } diff --git a/test/DBCoverageTest.php b/test/DBCoverageTest.php new file mode 100644 index 0000000..7d00fc7 --- /dev/null +++ b/test/DBCoverageTest.php @@ -0,0 +1,248 @@ +db = DbTestBootstrap::connect(); + DbTestBootstrap::createUsersAndProfiles($this->db); + + $this->db->insert('users', ['name' => 'ann', 'group_name' => 'a', 'is_active' => true]); + $this->db->insert('users', ['name' => 'bob', 'group_name' => 'a', 'is_active' => false]); + $this->db->insert('users', ['name' => 'cara', 'group_name' => 'b', 'is_active' => true]); + + $this->db->insert('profiles', ['user_id' => 1, 'city' => 'NY']); + $this->db->insert('profiles', ['user_id' => 2, 'city' => 'SF']); + } + + public function testTransactionCommitAndRollback(): void + { + $id = $this->db->transaction(function (DB $db) { + return $db->insert('users', ['name' => 'dave', 'group_name' => 'b', 'is_active' => true]); + }); + + $this->assertSame('4', $id); + $this->assertSame(4, $this->db->count('users')); + + try { + $this->db->transaction(function (DB $db): void { + $db->insert('users', ['name' => 'rolled', 'group_name' => 'c', 'is_active' => true]); + throw new RuntimeException('boom'); + }); + $this->fail('Expected RuntimeException'); + } catch (RuntimeException $e) { + $this->assertSame('boom', $e->getMessage()); + } + + $this->assertSame(4, $this->db->count('users')); + } + + public function testNestedTransactionsCommit(): void + { + $this->db->transaction(function (DB $db): void { + $db->insert('users', ['name' => 'outer-ok', 'group_name' => 'n', 'is_active' => true]); + + $db->transaction(function (DB $db): void { + $db->insert('users', ['name' => 'inner-ok', 'group_name' => 'n', 'is_active' => true]); + }); + }); + + $this->assertSame(5, $this->db->count('users')); + $this->assertSame(1, $this->db->count('users', ['name' => 'outer-ok'])); + $this->assertSame(1, $this->db->count('users', ['name' => 'inner-ok'])); + } + + public function testNestedTransactionsInnerRollbackAndOuterCommit(): void + { + $this->db->transaction(function (DB $db): void { + $db->insert('users', ['name' => 'outer-before', 'group_name' => 'n', 'is_active' => true]); + + try { + $db->transaction(function (DB $db): void { + $db->insert('users', ['name' => 'inner-fail', 'group_name' => 'n', 'is_active' => true]); + throw new RuntimeException('inner failure'); + }); + } catch (RuntimeException $e) { + $this->assertSame('inner failure', $e->getMessage()); + } + + $db->insert('users', ['name' => 'outer-after', 'group_name' => 'n', 'is_active' => true]); + }); + + $this->assertSame(5, $this->db->count('users')); + $this->assertSame(1, $this->db->count('users', ['name' => 'outer-before'])); + $this->assertSame(0, $this->db->count('users', ['name' => 'inner-fail'])); + $this->assertSame(1, $this->db->count('users', ['name' => 'outer-after'])); + } + + public function testNestedTransactionsUncaughtInnerErrorRollsBackOuter(): void + { + try { + $this->db->transaction(function (DB $db): void { + $db->insert('users', ['name' => 'outer-uncaught', 'group_name' => 'n', 'is_active' => true]); + + $db->transaction(function (DB $db): void { + $db->insert('users', ['name' => 'inner-uncaught', 'group_name' => 'n', 'is_active' => true]); + throw new RuntimeException('nested uncaught'); + }); + }); + $this->fail('Expected RuntimeException'); + } catch (RuntimeException $e) { + $this->assertSame('nested uncaught', $e->getMessage()); + } + + $this->assertSame(3, $this->db->count('users')); + $this->assertSame(0, $this->db->count('users', ['name' => 'outer-uncaught'])); + $this->assertSame(0, $this->db->count('users', ['name' => 'inner-uncaught'])); + } + + public function testManualTransactionMethods(): void + { + $this->assertTrue($this->db->beginTransaction()); + $this->db->insert('users', ['name' => 'temp', 'group_name' => 'z', 'is_active' => true]); + $this->assertTrue($this->db->rollBack()); + + $rows = $this->db->select('users', ['name' => 'temp'])->all(); + $this->assertCount(0, $rows); + } + + public function testCountGroupJoinOrderAndLimit(): void + { + $groupCount = $this->db->count('users', null, ['group' => 'group_name']); + $this->assertSame(2, $groupCount); + + $rows = $this->db->select('users', ['is_active' => 1], [ + 'fields' => ['users.id', 'users.name', 'p.city'], + 'join' => ['profiles p' => 'p.user_id = users.id'], + 'order' => ['users.id', 'desc'], + 'limit' => 1, + 'offset' => 0, + ])->all(); + + $this->assertCount(1, $rows); + $this->assertSame('ann', $rows[0]['name']); + $this->assertSame('NY', $rows[0]['city']); + } + + public function testSelectSupportsExprFieldWithAlias(): void + { + $rows = $this->db->select('users', ['name' => 'ann'], [ + 'fields' => [ + 'users.id', + 'is_ann' => Expr::raw("CASE WHEN users.name = 'ann' THEN 1 ELSE 0 END"), + ], + ])->all(); + + $this->assertCount(1, $rows); + $this->assertSame('1', (string)$rows[0]['is_ann']); + } + + public function testSelectSupportsForUpdateLock(): void + { + if (DbTestBootstrap::driver() === 'sqlite') { + $this->markTestSkipped('SQLite does not support FOR UPDATE.'); + } + + $query = $this->db->select('users', ['id' => 1], [ + 'fields' => ['users.id', 'users.name'], + 'lock' => 'update', + 'limit' => 1, + ]); + + $row = $query->fetch(); + $this->assertSame('ann', $row['name']); + $this->assertStringContainsString('FOR UPDATE', (string)$query->debugSql); + } + + public function testSelectSupportsBooleanLockAlias(): void + { + if (DbTestBootstrap::driver() === 'sqlite') { + $this->markTestSkipped('SQLite does not support FOR UPDATE.'); + } + + $query = $this->db->select('users', ['id' => 1], [ + 'fields' => ['users.id'], + 'lock' => true, + 'limit' => 1, + ]); + + $this->assertNotFalse($query->fetch()); + $this->assertStringContainsString('FOR UPDATE', (string)$query->debugSql); + } + + public function testGroupSupportsMultipleFieldsAsStringAndArray(): void + { + $countFromString = $this->db->count('users', null, ['group' => 'group_name, is_active']); + $this->assertSame(3, $countFromString); + + $countFromArray = $this->db->count('users', null, ['group' => ['group_name', 'is_active']]); + $this->assertSame(3, $countFromArray); + } + + public function testDebugAndHelpers(): void + { + $previousErrorLog = ini_get('error_log'); + ini_set('error_log', '/tmp/objectiveweb-db-test.log'); + + $this->db->debug(true); + $query = $this->db->query('SELECT 1 as ok'); + $this->assertSame('SELECT 1 as ok', $query->debugSql); + $this->db->debug(false); + + $clean = DB::array_cleanup(['a' => 1, 'b' => 2], ['a'], ['z' => 0]); + $this->assertSame(['z' => 0, 'a' => 1], $clean); + + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', DB::now()); + + if (is_string($previousErrorLog)) { + ini_set('error_log', $previousErrorLog); + } + } + + public function testConstructorWithParsedDsnArray(): void + { + $other = new DB(['scheme' => 'sqlite', 'path' => '/:memory:']); + $query = $other->query('SELECT 1 as value'); + $query->exec(); + $row = $query->fetch(); + $this->assertSame('1', (string) $row['value']); + } + + public function testUnsafeRawWhereAndJoinAreRejected(): void + { + $this->expectException(InvalidQueryException::class); + $this->db->select('users', '1=1')->all(); + } + + public function testInvalidLockModeIsRejected(): void + { + $this->expectException(InvalidQueryException::class); + $this->db->select('users', null, [ + 'lock' => 'invalid_lock', + ])->all(); + } + + public function testUnsafeRawJoinIsRejected(): void + { + $this->expectException(InvalidQueryException::class); + $this->db->select('users', null, [ + 'join' => ['JOIN profiles p ON p.user_id = users.id'], + ])->all(); + } + + public function testTransactionExceptionType(): void + { + $this->assertTrue(is_a(TransactionException::class, \RuntimeException::class, true)); + } +} diff --git a/test/DBTest.php b/test/DBTest.php index d46a5b8..3fed4b1 100644 --- a/test/DBTest.php +++ b/test/DBTest.php @@ -1,62 +1,41 @@ query('drop table if exists db_test')->exec(); - - self::$db->query('create table db_test - (`id` INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT, - `name` VARCHAR(255));')->exec(); + static::$db = DbTestBootstrap::connect(); + DbTestBootstrap::createDbTestTable(static::$db, false); } - public function testInsert() + public function testInsert(): void { - $r = self::$db->insert('db_test', array('name' => 'test')); - - $this->assertEquals(1, $r); - - $r = self::$db->insert('db_test', array('name' => 'test1')); - - $this->assertEquals(2, $r); - - $r = self::$db->insert('db_test', array('name' => 'test2')); + $r = self::$db->insert('db_test', ['name' => 'test']); + $this->assertEquals(1, (int) $r); - $this->assertEquals(3, $r); + $r = self::$db->insert('db_test', ['name' => 'test1']); + $this->assertEquals(2, (int) $r); - $r = self::$db->insert('db_test', array('name' => 'test3')); - - $this->assertEquals(4, $r); - - $r = self::$db->insert('db_test', array('name' => null)); - - $this->assertEquals(5, $r); + $r = self::$db->insert('db_test', ['name' => 'test2']); + $this->assertEquals(3, (int) $r); + $r = self::$db->insert('db_test', ['name' => 'test3']); + $this->assertEquals(4, (int) $r); + $r = self::$db->insert('db_test', ['name' => null]); + $this->assertEquals(5, (int) $r); } - /** - * @depends testInsert - */ - public function testSelectAll() + /** @depends testInsert */ + public function testSelectAll(): void { $rows = self::$db->select('db_test')->all(); @@ -64,35 +43,29 @@ public function testSelectAll() $this->assertEquals('test', $rows[0]['name']); } - /** - * @depends testInsert - */ - public function testSelectParams() + /** @depends testInsert */ + public function testSelectParams(): void { - $r = self::$db->select('db_test', array('name' => 'test3'), array('fields' => array('id')))->all(); + $r = self::$db->select('db_test', ['name' => 'test3'], ['fields' => ['id']])->all(); $this->assertNotEmpty($r); $keys = array_keys($r[0]); $this->assertEquals(1, count($keys)); $this->assertEquals('id', $keys[0]); - $this->assertEquals(4, $r[0]['id']); + $this->assertEquals(4, (int) $r[0]['id']); } - /** - * @depends testInsert - */ - public function testSelectLike() + /** @depends testInsert */ + public function testSelectLike(): void { - $rows = self::$db->select('db_test', array('name' => 'test%'))->all(); + $rows = self::$db->select('db_test', ['name' => 'test%'])->all(); $this->assertEquals(4, count($rows)); $this->assertEquals('test', $rows[0]['name']); } - /** - * @depends testInsert - */ - public function testSelectMap() + /** @depends testInsert */ + public function testSelectMap(): void { $map = self::$db->select('db_test')->map('id'); @@ -104,79 +77,63 @@ public function testSelectMap() $this->assertEquals(null, $map[5]['name']); } - /** - * @depends testInsert - */ - public function testSelectIn() + /** @depends testInsert */ + public function testSelectIn(): void { - $rows = self::$db->select('db_test', array('id' => array(2, 3, 4)))->all(); + $rows = self::$db->select('db_test', ['id' => [2, 3, 4]])->all(); $this->assertEquals(3, count($rows)); - $this->assertEquals(2, $rows[0]['id']); - $this->assertEquals(3, $rows[1]['id']); - $this->assertEquals(4, $rows[2]['id']); + $this->assertEquals(2, (int) $rows[0]['id']); + $this->assertEquals(3, (int) $rows[1]['id']); + $this->assertEquals(4, (int) $rows[2]['id']); } - /** - * @depends testInsert - */ - public function testUpdate() + /** @depends testInsert */ + public function testUpdate(): void { - $r = self::$db->update('db_test', array('name' => 'test4'), array('name' => 'test1')); + $r = self::$db->update('db_test', ['name' => 'test4'], ['name' => 'test1']); $this->assertEquals(1, $r); } - /** - * @depends testUpdate - */ - public function testSelectFetch() + /** @depends testUpdate */ + public function testSelectFetch(): void { - $r = self::$db->select('db_test', array('name' => 'test4'))->fetch(); + $r = self::$db->select('db_test', ['name' => 'test4'])->fetch(); $this->assertEquals('test4', $r['name']); - } - /** - * @depends testUpdate - */ - public function testSelectEmptyResults() + /** @depends testUpdate */ + public function testSelectEmptyResults(): void { - $r = self::$db->select('db_test', array('name' => 'test5'))->all(); + $r = self::$db->select('db_test', ['name' => 'test5'])->all(); $this->assertEmpty(count($r)); - } - /** - * @depends testUpdate - */ - public function testSelectNull() + /** @depends testUpdate */ + public function testSelectNull(): void { - $r = self::$db->select('db_test', array('name' => null))->all(); + $r = self::$db->select('db_test', ['name' => null])->all(); $this->assertEquals(1, count($r)); - - $this->assertEquals(5, $r[0]['id']); + $this->assertEquals(5, (int) $r[0]['id']); } - /** - * @depends testUpdate - */ - public function testDelete() + /** @depends testUpdate */ + public function testDelete(): void { $rows = self::$db->select('db_test')->all(); $this->assertEquals(count($rows), 5); - $r = self::$db->delete('db_test', array('name' => 'test1')); + $r = self::$db->delete('db_test', ['name' => 'test1']); $this->assertEquals(0, $r); - $r = self::$db->delete('db_test', array('name' => 'test4')); + $r = self::$db->delete('db_test', ['name' => 'test4']); $this->assertEquals(1, $r); $rows = self::$db->select('db_test')->all(); $this->assertEquals(count($rows), 4); - } } diff --git a/test/MySQLCompatibilityTest.php b/test/MySQLCompatibilityTest.php new file mode 100644 index 0000000..5a28309 --- /dev/null +++ b/test/MySQLCompatibilityTest.php @@ -0,0 +1,43 @@ +markTestSkipped('MYSQL_TEST_DSN not configured.'); + } + + $this->db = new DB( + $dsn, + getenv('MYSQL_TEST_USER') ?: 'root', + getenv('MYSQL_TEST_PASSWORD') ?: 'root' + ); + + $this->db->query('DROP TABLE IF EXISTS mysql_compat')->exec(); + $this->db->query('CREATE TABLE mysql_compat (id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT NOT NULL, name VARCHAR(100), score INT)')->exec(); + } + + public function testMysqlCrudFlow(): void + { + $this->db->insert('mysql_compat', ['name' => 'alice', 'score' => 10]); + $this->db->insert('mysql_compat', ['name' => 'bob', 'score' => 20]); + + $this->assertSame(2, $this->db->count('mysql_compat')); + + $this->db->update('mysql_compat', ['score' => 30], ['name' => 'bob']); + $row = $this->db->select('mysql_compat', ['name' => 'bob'])->fetch(); + $this->assertSame('30', (string) $row['score']); + + $this->assertSame(1, $this->db->delete('mysql_compat', ['name' => 'alice'])); + $this->assertSame(1, $this->db->count('mysql_compat')); + } +} diff --git a/test/PgSQLCompatibilityTest.php b/test/PgSQLCompatibilityTest.php new file mode 100644 index 0000000..8cdedf9 --- /dev/null +++ b/test/PgSQLCompatibilityTest.php @@ -0,0 +1,43 @@ +markTestSkipped('PGSQL_TEST_DSN not configured.'); + } + + $this->db = new DB( + $dsn, + getenv('PGSQL_TEST_USER') ?: 'postgres', + getenv('PGSQL_TEST_PASSWORD') ?: 'postgres' + ); + + $this->db->query('DROP TABLE IF EXISTS pgsql_compat')->exec(); + $this->db->query('CREATE TABLE pgsql_compat (id SERIAL PRIMARY KEY, name VARCHAR(100), score INTEGER)')->exec(); + } + + public function testPgsqlCrudFlow(): void + { + $this->db->insert('pgsql_compat', ['name' => 'alice', 'score' => 10]); + $this->db->insert('pgsql_compat', ['name' => 'bob', 'score' => 20]); + + $this->assertSame(2, $this->db->count('pgsql_compat')); + + $this->db->update('pgsql_compat', ['score' => 30], ['name' => 'bob']); + $row = $this->db->select('pgsql_compat', ['name' => 'bob'])->fetch(); + $this->assertSame('30', (string) $row['score']); + + $this->assertSame(1, $this->db->delete('pgsql_compat', ['name' => 'alice'])); + $this->assertSame(1, $this->db->count('pgsql_compat')); + } +} diff --git a/test/QueryAndTableCoverageTest.php b/test/QueryAndTableCoverageTest.php new file mode 100644 index 0000000..1047697 --- /dev/null +++ b/test/QueryAndTableCoverageTest.php @@ -0,0 +1,105 @@ +db = DbTestBootstrap::connect(); + DbTestBootstrap::createThings($this->db); + + $this->table = $this->db->table('things'); + $this->table->insert(['name' => 'x1', 'kind' => 'x']); + $this->table->insert(['name' => 'x2', 'kind' => 'x']); + $this->table->insert(['name' => 'x2', 'kind' => 'y']); + $this->table->insert(['name' => 'y1', 'kind' => 'y']); + } + + public function testQueryBindFetchAllAndExecUpdate(): void + { + $query = $this->db->query('SELECT * FROM things WHERE id = :id'); + $query->bind('id', 1)->exec(); + + $first = $query->fetch(); + $this->assertSame('x1', $first['name']); + + $this->assertCount(0, $query->all()); + + $updated = $this->db->query('UPDATE things SET name = :name WHERE id = :id')->exec([ + 'name' => 'x1-updated', + 'id' => 1, + ]); + + $this->assertSame(1, $updated); + $this->assertSame('x1-updated', $this->table->get(1)['name']); + } + + public function testQueryMapInvalidFieldThrows(): void + { + $query = $this->db->select('things'); + + $this->expectException(InvalidQueryException::class); + $query->map('missing_field'); + } + + public function testTableInsertFindByDeleteScalarAndNotFound(): void + { + $inserted = $this->table->insert(['name' => 'z1', 'kind' => 'z']); + $this->assertArrayHasKey('id', $inserted); + + $found = $this->table->findBy('kind', 'x'); + $this->assertSame(2, count($found)); + + $deleted = $this->table->delete(2); + $this->assertSame(1, $deleted); + + $this->expectException(NotFoundException::class); + $this->table->get(9999); + } + + public function testTableGetWithJsonFilterAndRange(): void + { + $data = $this->table->select([], [ + 'filter' => '{"kind":"x"}', + 'range' => '[0,0]', + 'sort' => ['id', 'asc'], + ]); + + $this->assertSame(1, count($data)); + $this->assertSame(2, $data->total()); + } + + public function testSelectParamsFilterMergesWithFirstFilterArgument(): void + { + $data = $this->table->select( + ['name' => 'x2'], + ['filter' => ['kind' => 'x']] + ); + + $this->assertSame(1, count($data)); + $this->assertSame('x2', $data[0]['name']); + $this->assertSame('x', $data[0]['kind']); + } + + public function testOrderSupportsIsNullExpression(): void + { + $this->table->insert(['name' => null, 'kind' => 'z']); + + $rows = $this->db->select('things', null, [ + 'order' => ['name is null desc', 'id asc'], + ])->all(); + + $this->assertNull($rows[0]['name']); + } +} diff --git a/test/SQLBehaviorCoverageTest.php b/test/SQLBehaviorCoverageTest.php new file mode 100644 index 0000000..82d380f --- /dev/null +++ b/test/SQLBehaviorCoverageTest.php @@ -0,0 +1,305 @@ +db = DbTestBootstrap::connect(); + + $this->db->query('DROP TABLE IF EXISTS item_meta')->exec(); + $this->db->query('DROP TABLE IF EXISTS items')->exec(); + $this->db->query('DROP TABLE IF EXISTS person_links')->exec(); + $this->db->query('DROP TABLE IF EXISTS people')->exec(); + + $this->db->query(sprintf( + 'CREATE TABLE items (%s, name VARCHAR(255), kind VARCHAR(50), score INTEGER, deleted_at VARCHAR(25))', + $this->idDefinition() + ))->exec(); + + $this->db->query(sprintf( + 'CREATE TABLE item_meta (%s, item_id INTEGER, tag VARCHAR(50))', + $this->idDefinition() + ))->exec(); + $this->db->query(sprintf( + 'CREATE TABLE people (%s, name VARCHAR(255))', + $this->idDefinition() + ))->exec(); + $this->db->query(sprintf( + 'CREATE TABLE person_links (%s, child_id INTEGER, parent_a_id INTEGER, parent_b_id INTEGER)', + $this->idDefinition() + ))->exec(); + + $this->db->insert('items', ['name' => 'alpha', 'kind' => 'x', 'score' => 10, 'deleted_at' => null]); + $this->db->insert('items', ['name' => 'beta', 'kind' => 'x', 'score' => 20, 'deleted_at' => '2025-01-01']); + $this->db->insert('items', ['name' => 'gamma', 'kind' => 'y', 'score' => 5, 'deleted_at' => null]); + + $this->db->insert('item_meta', ['item_id' => 1, 'tag' => 't1']); + $this->db->insert('item_meta', ['item_id' => 2, 'tag' => 't2']); + + $this->db->insert('people', ['name' => 'child']); + $this->db->insert('people', ['name' => 'parent_a']); + $this->db->insert('people', ['name' => 'parent_b']); + $this->db->insert('person_links', ['child_id' => 1, 'parent_a_id' => 2, 'parent_b_id' => 3]); + } + + public function testWhereOperatorBranches(): void + { + $notEqual = $this->db->select('items', ['!name' => 'alpha'])->all(); + $this->assertCount(2, $notEqual); + + $notLike = $this->db->select('items', ['!name' => 'a%'])->all(); + $this->assertCount(2, $notLike); + + $isNull = $this->db->select('items', ['deleted_at' => null])->all(); + $this->assertCount(2, $isNull); + + $isNotNull = $this->db->select('items', ['!deleted_at' => null])->all(); + $this->assertCount(1, $isNotNull); + + $emptyIn = $this->db->select('items', ['id' => []])->all(); + $this->assertCount(0, $emptyIn); + + $emptyNotIn = $this->db->select('items', ['!id' => []])->all(); + $this->assertCount(3, $emptyNotIn); + } + + public function testFieldAggregationGroupAndOrderModes(): void + { + $grouped = $this->db->select('items', null, [ + 'fields' => ['kind', 'total' => 'COUNT(*)', 'max_score' => 'MAX(score)'], + 'group' => 'kind', + 'order' => 'kind ASC', + ])->all(); + + $this->assertCount(2, $grouped); + $this->assertSame('x', $grouped[0]['kind']); + $this->assertSame('2', (string) $grouped[0]['total']); + + $onlyTableWildcard = $this->db->select('items', ['id' => 1], [ + 'fields' => ['items.*'], + ])->fetch(); + + $this->assertSame('alpha', $onlyTableWildcard['name']); + } + + public function testSelectSupportsCountAndAvgFunctionFields(): void + { + $row = $this->db->select('items', ['kind' => 'x'], [ + 'fields' => [ + 'total' => 'COUNT(*)', + 'avg_score' => 'AVG(score)', + ], + ])->fetch(); + + $this->assertNotFalse($row); + $this->assertSame('2', (string) $row['total']); + $this->assertEqualsWithDelta(15.0, (float) $row['avg_score'], 0.00001); + } + + public function testSelectSupportsCountCaseWhenFunctionFields(): void + { + $row = $this->db->select('items', null, [ + 'fields' => [ + 'activated_count' => 'COUNT(CASE WHEN items.deleted_at IS NOT NULL THEN 1 END)', + 'inactive_score_count' => 'COUNT(CASE WHEN items.score IS NULL THEN 1 END)', + ], + ])->fetch(); + + $this->assertNotFalse($row); + $this->assertSame('1', (string) $row['activated_count']); + $this->assertSame('0', (string) $row['inactive_score_count']); + } + + public function testSelectSupportsCoalesceFunctionFields(): void + { + $rows = $this->db->select('items', null, [ + 'fields' => [ + 'items.id', + 'display_tag' => 'coalesce(m.tag, items.kind)', + ], + 'join' => ['left:item_meta m' => 'm.item_id = items.id'], + 'order' => 'items.id ASC', + ])->all(); + + $this->assertCount(3, $rows); + $this->assertSame('t1', $rows[0]['display_tag']); + $this->assertSame('t2', $rows[1]['display_tag']); + $this->assertSame('y', $rows[2]['display_tag']); + } + + public function testSelectSupportsDottedAliases(): void + { + $row = $this->db->select('items', ['items.id' => 1], [ + 'fields' => [ + 'item.name' => 'items.name', + 'item.kind' => 'items.kind', + ], + ])->fetch(); + + $this->assertNotFalse($row); + $this->assertArrayHasKey('item.name', $row); + $this->assertArrayHasKey('item.kind', $row); + $this->assertSame('alpha', $row['item.name']); + $this->assertSame('x', $row['item.kind']); + } + + public function testSelectRejectsInvalidCountCaseWhenFunctionFields(): void + { + $this->expectException(InvalidQueryException::class); + $this->db->select('items', null, [ + 'fields' => [ + 'bad' => 'COUNT(CASE WHEN items.deleted_at IS NOT NULL THEN END)', + ], + ])->all(); + } + + public function testSelectRejectsInvalidCoalesceFunctionFields(): void + { + $this->expectException(InvalidQueryException::class); + $this->db->select('items', null, [ + 'fields' => [ + 'bad' => 'coalesce(items.name)', + ], + ])->all(); + } + + public function testJoinVariantsAndOrderList(): void + { + $rows = $this->db->select('items', null, [ + 'fields' => ['items.id', 'items.name', 'm.tag'], + 'join' => ['left:item_meta m' => 'm.item_id = items.id'], + 'order' => 'items.id DESC, items.name ASC', + ])->all(); + + $this->assertCount(3, $rows); + $this->assertSame('gamma', $rows[0]['name']); + $this->assertNull($rows[0]['tag']); + } + + public function testJoinSameTableTwiceWithDifferentAliases(): void + { + $rows = $this->db->select('person_links', ['person_links.child_id' => 1], [ + 'fields' => [ + 'person_links.child_id', + 'parent_a_name' => 'p1.name', + 'parent_b_name' => 'p2.name', + ], + 'join' => [ + 'inner:people p1' => 'p1.id = person_links.parent_a_id', + 'inner:people p2' => 'p2.id = person_links.parent_b_id', + ], + ])->all(); + + $this->assertCount(1, $rows); + $this->assertSame('parent_a', $rows[0]['parent_a_name']); + $this->assertSame('parent_b', $rows[0]['parent_b_name']); + } + + public function testCrossJoinWithStructuredFormat(): void + { + $rows = $this->db->select('items', ['items.id' => 1], [ + 'fields' => ['item_name' => 'items.name', 'person_name' => 'p.name'], + 'join' => [ + ['type' => 'cross', 'table' => 'people', 'alias' => 'p'], + ], + ])->all(); + + $this->assertCount(3, $rows); + $this->assertSame('alpha', $rows[0]['item_name']); + } + + public function testRightJoinSupport(): void + { + if ($this->driver() === 'sqlite') { + $this->markTestSkipped('RIGHT JOIN is not supported by SQLite.'); + } + + $rows = $this->db->select('people', ['people.name' => 'parent_a'], [ + 'fields' => [ + 'parent_name' => 'people.name', + 'child_name' => 'p2.name', + ], + 'join' => [ + ['type' => 'right', 'table' => 'person_links', 'alias' => 'l', 'on' => 'l.parent_a_id = people.id'], + ['type' => 'inner', 'table' => 'people', 'alias' => 'p2', 'on' => 'p2.id = l.child_id'], + ], + ])->all(); + + $this->assertCount(1, $rows); + $this->assertSame('parent_a', $rows[0]['parent_name']); + $this->assertSame('child', $rows[0]['child_name']); + } + + public function testFullOuterJoinSupport(): void + { + if ($this->driver() !== 'pgsql') { + $this->markTestSkipped('FULL OUTER JOIN test runs on PostgreSQL only.'); + } + + $rows = $this->db->select('people', null, [ + 'fields' => ['people.id', 'p2.id'], + 'join' => [ + ['type' => 'full', 'table' => 'people', 'alias' => 'p2', 'on' => 'p2.id = people.id'], + ], + ])->all(); + + $this->assertCount(3, $rows); + } + + public function testMutationSafetyAndInvalidSqlInputs(): void + { + $this->expectException(InvalidQueryException::class); + $this->db->update('items', ['name' => 'unsafe']); + } + + public function testDeleteSafetyAndInvalidClauses(): void + { + try { + $this->db->delete('items', null); + $this->fail('Expected InvalidQueryException for delete without where'); + } catch (InvalidQueryException $e) { + $this->assertStringContainsString('Unsafe DELETE', $e->getMessage()); + } + + $this->expectException(InvalidQueryException::class); + $this->db->select('items', null, ['order' => 'name; DROP TABLE items']); + } + + public function testInvalidGroupAndIdentifierRejection(): void + { + try { + $this->db->select('items', null, ['group' => ['kind', '']]); + $this->fail('Expected InvalidQueryException for invalid group'); + } catch (InvalidQueryException $e) { + $this->assertStringContainsString('Invalid group value', $e->getMessage()); + } + + $this->expectException(InvalidQueryException::class); + $this->db->select('bad-table')->all(); + } + + private function idDefinition(): string + { + return match ((string) (getenv('TEST_DB_DRIVER') ?: 'sqlite')) { + 'mysql' => 'id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT NOT NULL', + 'pgsql' => 'id SERIAL PRIMARY KEY', + default => 'id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL', + }; + } + + private function driver(): string + { + return (string) (getenv('TEST_DB_DRIVER') ?: 'sqlite'); + } +} diff --git a/test/Support/DbTestBootstrap.php b/test/Support/DbTestBootstrap.php new file mode 100644 index 0000000..e70a91c --- /dev/null +++ b/test/Support/DbTestBootstrap.php @@ -0,0 +1,101 @@ + new DB( + getenv('MYSQL_TEST_DSN') ?: sprintf( + 'mysql:dbname=%s;host=%s;port=%s;charset=utf8mb4', + getenv('MYSQL_TEST_DB') ?: 'objectiveweb_test', + getenv('MYSQL_TEST_HOST') ?: '127.0.0.1', + getenv('MYSQL_TEST_PORT') ?: '3306' + ), + getenv('MYSQL_TEST_USER') ?: 'root', + getenv('MYSQL_TEST_PASSWORD') ?: 'root' + ), + 'pgsql' => new DB( + getenv('PGSQL_TEST_DSN') ?: sprintf( + 'pgsql:dbname=%s;host=%s;port=%s', + getenv('PGSQL_TEST_DB') ?: 'objectiveweb_test', + getenv('PGSQL_TEST_HOST') ?: '127.0.0.1', + getenv('PGSQL_TEST_PORT') ?: '5432' + ), + getenv('PGSQL_TEST_USER') ?: 'postgres', + getenv('PGSQL_TEST_PASSWORD') ?: 'postgres' + ), + default => new DB(getenv('SQLITE_TEST_DSN') ?: 'sqlite::memory:'), + }; + } + + public static function createDbTestTable(DB $db, bool $withExtraFields): void + { + self::dropTable($db, 'db_test'); + + $columns = ['name VARCHAR(255)']; + if ($withExtraFields) { + $columns[] = 'f1 VARCHAR(255)'; + $columns[] = 'f2 VARCHAR(255)'; + $columns[] = 'f3 VARCHAR(255)'; + } + + $db->query(sprintf( + 'CREATE TABLE db_test (%s, %s)', + self::idDefinition(), + implode(', ', $columns) + ))->exec(); + } + + public static function createUsersAndProfiles(DB $db): void + { + self::dropTable($db, 'profiles'); + self::dropTable($db, 'users'); + + $boolType = self::driver() === 'pgsql' ? 'BOOLEAN' : 'INTEGER'; + + $db->query(sprintf( + 'CREATE TABLE users (%s, name VARCHAR(255), group_name VARCHAR(255), is_active %s)', + self::idDefinition(), + $boolType + ))->exec(); + + $db->query(sprintf( + 'CREATE TABLE profiles (%s, user_id INTEGER, city VARCHAR(255))', + self::idDefinition() + ))->exec(); + } + + public static function createThings(DB $db): void + { + self::dropTable($db, 'things'); + + $db->query(sprintf( + 'CREATE TABLE things (%s, name VARCHAR(255), kind VARCHAR(255))', + self::idDefinition() + ))->exec(); + } + + public static function driver(): string + { + return (string) (getenv('TEST_DB_DRIVER') ?: 'sqlite'); + } + + private static function dropTable(DB $db, string $table): void + { + $db->query(sprintf('DROP TABLE IF EXISTS %s', $table))->exec(); + } + + private static function idDefinition(): string + { + return match (self::driver()) { + 'mysql' => 'id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT NOT NULL', + 'pgsql' => 'id SERIAL PRIMARY KEY', + default => 'id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL', + }; + } +} diff --git a/test/TableFromClassTest.php b/test/TableFromClassTest.php index 5542c57..606bf74 100644 --- a/test/TableFromClassTest.php +++ b/test/TableFromClassTest.php @@ -1,36 +1,27 @@ query('drop table if exists db_test')->exec(); - - $db->query('create table db_test - (`id` INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT, - `name` VARCHAR(255));')->exec(); + $db = DbTestBootstrap::connect(); + DbTestBootstrap::createDbTestTable($db, true); - self::$table = $db->table('DbTestTable'); + static::$table = $db->table(DbTestTable::class); } - public function testClass() + public function testClass(): void { $this->assertInstanceOf(DbTestTable::class, self::$table); } diff --git a/test/TableModelTest.php b/test/TableModelTest.php new file mode 100644 index 0000000..1fe2a1f --- /dev/null +++ b/test/TableModelTest.php @@ -0,0 +1,150 @@ + $data */ + public function __construct(array $data) + { + $this->id = (int) $data['id']; + $this->name = (string) $data['name']; + } +} + +final class TableValidatedModel extends Model +{ + protected static array $validFields = ['name', 'age']; + + protected static array $creationRules = [ + 'name' => [ + 'required' => true, + 'filter' => FILTER_UNSAFE_RAW, + 'validate' => [self::class, 'validateName'], + ], + 'age' => [ + 'required' => true, + 'filter' => FILTER_VALIDATE_INT, + 'validate' => [self::class, 'validateAge'], + ], + ]; + + public static function validateName(mixed $value): bool|string + { + if (!is_string($value) || strlen(trim($value)) < 2) { + return 'name must have at least 2 chars'; + } + + return true; + } + + public static function validateAge(mixed $value): bool|string + { + if (!is_int($value) || $value < 0) { + return 'age must be a non-negative integer'; + } + + return true; + } +} + +class TableModelTest extends TestCase +{ + private DB $db; + + protected function setUp(): void + { + $this->db = DbTestBootstrap::connect(); + + $this->db->query('DROP TABLE IF EXISTS model_test')->exec(); + $this->db->query(sprintf('CREATE TABLE model_test (%s, name VARCHAR(255), age INTEGER)', $this->idDefinition()))->exec(); + + $this->db->insert('model_test', ['name' => 'first', 'age' => 10]); + $this->db->insert('model_test', ['name' => 'second', 'age' => 20]); + } + + public function testTableReturnsModelObjectsUsingConstructorArray(): void + { + $table = $this->db->table('model_test', ['model' => TableModelCtor::class]); + + $rows = $table->select(); + $this->assertInstanceOf(TableModelCtor::class, $rows[0]); + $this->assertSame('first', $rows[0]->name); + + $single = $table->get(2); + $this->assertInstanceOf(TableModelCtor::class, $single); + $this->assertSame(2, $single->id); + } + + public function testInvalidModelClassThrows(): void + { + $this->expectException(InvalidQueryException::class); + $this->db->table('model_test', ['model' => 'NotARealModelClass']); + } + + public function testValidatedModelFiltersAndValidatesOnCreate(): void + { + $table = $this->db->table('model_test', ['model' => TableValidatedModel::class]); + $id = $table->insert(['name' => 'third', 'age' => 30, 'ignored' => 'x']); + + $this->assertNotNull($id); + + $row = $this->db->select('model_test', ['id' => (int) $id['id']])->fetch(); + $this->assertSame('third', $row['name']); + $this->assertSame('30', (string) $row['age']); + } + + public function testValidatedModelRejectsInvalidCreatePayload(): void + { + $table = $this->db->table('model_test', ['model' => TableValidatedModel::class]); + + $this->expectException(ModelValidationException::class); + $table->insert(['age' => 30]); + } + + public function testValidatedModelSupportsModelInstancePayload(): void + { + $table = $this->db->table('model_test', ['model' => TableValidatedModel::class]); + $model = new TableValidatedModel(['name' => 'fourth', 'age' => 44]); + + $id = $table->insert($model); + $this->assertNotNull($id); + } + + public function testValidatedModelRejectsUpdateWithoutValidFields(): void + { + $table = $this->db->table('model_test', ['model' => TableValidatedModel::class]); + + $this->expectException(ModelValidationException::class); + $table->update(1, ['ignored' => 'x']); + } + + public function testValidatedModelRejectsNegativeAgeWithCustomValidator(): void + { + $table = $this->db->table('model_test', ['model' => TableValidatedModel::class]); + + $this->expectException(ModelValidationException::class); + $table->insert(['name' => 'bad', 'age' => -1]); + } + + private function idDefinition(): string + { + return match ((string) (getenv('TEST_DB_DRIVER') ?: 'sqlite')) { + 'mysql' => 'id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT NOT NULL', + 'pgsql' => 'id SERIAL PRIMARY KEY', + default => 'id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL', + }; + } +}