diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 68fcd9a7..1a783cf3 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -11,6 +11,6 @@ jobs: runs-on: ubuntu-latest steps: # Drafts your next Release notes as Pull Requests are merged into "master" - - uses: toolmantim/release-drafter@master + - uses: toolmantim/release-drafter@v5.6.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index f0168ebd..dfd4b13d 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -19,10 +19,10 @@ jobs: php: ['7.2', '7.3', 'latest'] services: mysql: - image: mysql:5.7 + image: mariadb:10.4.12 env: MYSQL_ROOT_PASSWORD: password - DB_DATABASE: dsql_test + MYSQL_DATABASE: dsql_test options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5 postgres: image: postgres:10-alpine @@ -31,7 +31,7 @@ jobs: POSTGRES_USER: postgres options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - run: php --version - name: Get Composer Cache Directory id: composer-cache @@ -49,10 +49,11 @@ jobs: - name: Run Tests run: | mkdir -p build/logs - mysql -uroot -ppassword -h mysql -e 'CREATE DATABASE dsql_test;' + # mysql -uroot -ppassword -h mysql -e 'CREATE DATABASE dsql_test;' PGPASSWORD=password psql -h postgres -c 'create database "atk4-dsql-test";' -U postgres + - name: SQLite Testing - run: vendor/bin/phpunit --configuration phpunit.xml --coverage-text --exclude-group dns + run: vendor/bin/phpunit -d zend.enable_gc=0 --configuration phpunit.xml --coverage-text --exclude-group dns # remove " -d zend.enable_gc=0" once PHP segfault in GH Actions is fixed, see https://github.com/atk4/dsql/issues/176 - name: MySQL Testing run: vendor/bin/phpunit --configuration phpunit-mysql-workflow.xml --exclude-group dns diff --git a/docs/expressions.rst b/docs/expressions.rst index f190f481..0d0014a6 100644 --- a/docs/expressions.rst +++ b/docs/expressions.rst @@ -326,19 +326,23 @@ parts of the query. You must not call them in normal circumstances. .. php:method:: _escape($sql_code) Always surrounds `$sql code` with back-ticks. + + This escaping method is automatically used for `{...}` expression template tags . .. php:method:: _escapeSoft($sql_code) Surrounds `$sql code` with back-ticks. + This escaping method is automatically used for `{{...}}` expression template tags . + It will smartly escape table.field type of strings resulting in `table`.`field`. Will do nothing if it finds "*", "`" or "(" character in `$sql_code`:: - $query->_escape('first_name'); // `first_name` - $query->_escape('first.name'); // `first`.`name` - $query->_escape('(2+2)'); // (2+2) - $query->_escape('*'); // * + $query->_escapeSoft('first_name'); // `first_name` + $query->_escapeSoft('first.name'); // `first`.`name` + $query->_escapeSoft('(2+2)'); // (2+2) + $query->_escapeSoft('*'); // * .. php:method:: _param($value) @@ -346,6 +350,8 @@ parts of the query. You must not call them in normal circumstances. rendering. Consider using :php:meth:`_consume()` instead, which will also handle nested expressions properly. + This escaping method is automatically used for `[...]` expression template tags . + .. _properties: diff --git a/docs/queries.rst b/docs/queries.rst index d51b0095..66dc5ecb 100644 --- a/docs/queries.rst +++ b/docs/queries.rst @@ -599,6 +599,57 @@ For a more complex join conditions, you can pass second argument as expression:: $q->join('address a', new Expression('a.name like u.pattern')); +Use WITH cursors +--------------------------- + +.. php:method:: with(Query $cursor, string $alias, ?array $fields = null, bool $recursive = false) + + If you want to add `WITH` cursor statement in your SQL, then use this method. + First parameter defines sub-query to use. Second parameter defines alias of this cursor. + By using third, optional argument you can set aliases for columns in cursor. + And finally forth, optional argument set if cursors will be recursive or not. + + You can add more than one cursor in your query. + + Did you know: you can use these cursors when joining your query to other tables. Just join cursor instead. + +.. php:method:: withRecursive(Query $cursor, string $alias, ?array $fields = null) + + Same as :php:meth:`with()`, but always sets it as recursive. + + Keep in mind that if any of cursors added in your query will be recursive, then all cursors will + be set recursive. That's how SQL want it to be. + + Example:: + + $quotes = $q->table('quotes') + ->field('emp_id') + ->field($q->expr('sum([])', ['total_net'])) + ->group('emp_id'); + $invoices = $q()->table('invoices') + ->field('emp_id') + ->field($q->expr('sum([])', ['total_net'])) + ->group('emp_id'); + $employees = $q + ->with($quotes, 'q', ['emp','quoted']) + ->with($invoices, 'i', ['emp','invoiced']) + ->table('employees') + ->join('q.emp') + ->join('i.emp') + ->field(['name', 'salary', 'q.quoted', 'i.invoiced']); + + This generates SQL below: + +.. code-block:: sql + + with + `q` (`emp`,`quoted`) as (select `emp_id`,sum(`total_net`) from `quotes` group by `emp_id`), + `i` (`emp`,`invoiced`) as (select `emp_id`,sum(`total_net`) from `invoices` group by `emp_id`) + select `name`,`salary`,`q`.`quoted`,`i`.`invoiced` + from `employees` + left join `q` on `q`.`emp` = `employees`.`id` + left join `i` on `i`.`emp` = `employees`.`id` + Limiting result-set ------------------- diff --git a/src/Connection.php b/src/Connection.php index 17517281..d2f044ed 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -15,10 +15,10 @@ class Connection use \atk4\core\DIContainerTrait; /** @var string Query classname */ - protected $query_class = 'atk4\dsql\Query'; + protected $query_class = Query::class; /** @var string Expression classname */ - protected $expression_class = 'atk4\dsql\Expression'; + protected $expression_class = Expression::class; /** @var Connection|\PDO Connection or PDO object */ protected $connection = null; @@ -71,13 +71,11 @@ public static function normalizeDSN($dsn, $user = null, $pass = null) // If parts are usable, convert DSN format if ($parts !== false && isset($parts['host'], $parts['path'])) { // DSN is using URL-like format, so we need to convert it - $dsn = - $parts['scheme']. - ':host='.$parts['host']. - (isset($parts['port']) ? ';port='.$parts['port'] : ''). - ';dbname='.substr($parts['path'], 1); - $user = $user !== null ? $user : (isset($parts['user']) ? $parts['user'] : null); - $pass = $pass !== null ? $pass : (isset($parts['pass']) ? $parts['pass'] : null); + $dsn = $parts['scheme'].':host='.$parts['host'] + .(isset($parts['port']) ? ';port='.$parts['port'] : '') + .';dbname='.substr($parts['path'], 1); + $user = $user ?? ($parts['user'] ?? null); + $pass = $pass ?? ($parts['pass'] ?? null); } // If it's still array, then simply use it @@ -118,25 +116,25 @@ public static function connect($dsn, $user = null, $password = null, $args = []) // If it's already PDO object, then we simply use it if ($dsn instanceof \PDO) { $driver = $dsn->getAttribute(\PDO::ATTR_DRIVER_NAME); - $connectionClass = '\\atk4\\dsql\\Connection'; + $connectionClass = self::class; $queryClass = null; $expressionClass = null; switch ($driver) { case 'pgsql': - $connectionClass = '\\atk4\\dsql\\Connection_PgSQL'; - $queryClass = 'atk4\dsql\Query_PgSQL'; + $connectionClass = Connection_PgSQL::class; + $queryClass = Query_PgSQL::class; break; case 'oci': - $connectionClass = '\\atk4\\dsql\\Connection_Oracle'; + $connectionClass = Connection_Oracle::class; break; case 'sqlite': - $queryClass = 'atk4\dsql\Query_SQLite'; + $queryClass = Query_SQLite::class; break; case 'mysql': - $expressionClass = 'atk4\dsql\Expression_MySQL'; + $expressionClass = Expression_MySQL::class; default: // Default, for backwards compatibility - $queryClass = 'atk4\dsql\Query_MySQL'; + $queryClass = Query_MySQL::class; break; } @@ -163,8 +161,8 @@ public static function connect($dsn, $user = null, $password = null, $args = []) case 'mysql': $c = new static(array_merge([ 'connection' => new \PDO($dsn['dsn'], $dsn['user'], $dsn['pass']), - 'expression_class' => 'atk4\dsql\Expression_MySQL', - 'query_class' => 'atk4\dsql\Query_MySQL', + 'expression_class' => Expression_MySQL::class, + 'query_class' => Query_MySQL::class, 'driver' => $dsn['driver'], ], $args)); break; @@ -172,7 +170,7 @@ public static function connect($dsn, $user = null, $password = null, $args = []) case 'sqlite': $c = new static(array_merge([ 'connection' => new \PDO($dsn['dsn'], $dsn['user'], $dsn['pass']), - 'query_class' => 'atk4\dsql\Query_SQLite', + 'query_class' => Query_SQLite::class, 'driver' => $dsn['driver'], ], $args)); break; diff --git a/src/Connection_Oracle.php b/src/Connection_Oracle.php index 2c505c41..8a65cf25 100644 --- a/src/Connection_Oracle.php +++ b/src/Connection_Oracle.php @@ -13,7 +13,7 @@ class Connection_Oracle extends Connection { /** @var string Query classname */ - protected $query_class = 'atk4\dsql\Query_Oracle'; + protected $query_class = Query_Oracle::class; /** * Add some configuration for current connection session. diff --git a/src/Connection_Oracle12.php b/src/Connection_Oracle12.php index d50f2f9f..47b732e3 100644 --- a/src/Connection_Oracle12.php +++ b/src/Connection_Oracle12.php @@ -13,5 +13,5 @@ class Connection_Oracle12 extends Connection_Oracle { /** @var string Query classname */ - protected $query_class = 'atk4\dsql\Query_Oracle12c'; + protected $query_class = Query_Oracle12c::class; } diff --git a/src/Connection_PgSQL.php b/src/Connection_PgSQL.php index 359f9c49..e1b36465 100644 --- a/src/Connection_PgSQL.php +++ b/src/Connection_PgSQL.php @@ -13,7 +13,7 @@ class Connection_PgSQL extends Connection { /** @var string Query classname */ - protected $query_class = 'atk4\dsql\Query_PgSQL'; + protected $query_class = Query_PgSQL::class; /** * Return last inserted ID value. diff --git a/src/Expression.php b/src/Expression.php index e03f66c0..24175b26 100644 --- a/src/Expression.php +++ b/src/Expression.php @@ -365,7 +365,7 @@ protected function _escapeSoft($value) * * @param string $value * - * @return string + * @return Expression */ public function escape($value) { @@ -390,10 +390,9 @@ protected function _escape($value) } // in all other cases we should escape - return - $this->escape_char - .str_replace($this->escape_char, $this->escape_char.$this->escape_char, $value) - .$this->escape_char; + $c = $this->escape_char; + + return $c.str_replace($c, $c.$c, $value).$c; } /** @@ -436,10 +435,21 @@ public function render() } $res = preg_replace_callback( - '/\[[a-z0-9_]*\]|{[a-z0-9_]*}/i', + // param | escape | escapeSoft + '/\[[a-z0-9_]*\]|{[a-z0-9_]*}|{{[a-z0-9_]*}}/i', function ($matches) use (&$nameless_count) { $identifier = substr($matches[0], 1, -1); - $escaping = ($matches[0][0] == '[') ? 'param' : 'escape'; + + if ($matches[0][0] == '[') { + $escaping = 'param'; + } elseif ($matches[0][0] == '{') { + if ($matches[0][1] == '{') { + $escaping = 'soft-escape'; + $identifier = substr($identifier, 1, -1); + } else { + $escaping = 'escape'; + } + } // Allow template to contain [] if ($identifier === '') { diff --git a/src/Query.php b/src/Query.php index fc5efb51..96e94854 100644 --- a/src/Query.php +++ b/src/Query.php @@ -29,14 +29,14 @@ class Query extends Expression public $defaultField = '*'; /** @var string Expression classname */ - protected $expression_class = 'atk4\dsql\Expression'; + protected $expression_class = Expression::class; /** * SELECT template. * * @var string */ - protected $template_select = 'select[option] [field] [from] [table][join][where][group][having][order][limit]'; + protected $template_select = '[with]select[option] [field] [from] [table][join][where][group][having][order][limit]'; /** * INSERT template. @@ -57,14 +57,14 @@ class Query extends Expression * * @var string */ - protected $template_delete = 'delete [from] [table_noalias][where][having]'; + protected $template_delete = '[with]delete [from] [table_noalias][where][having]'; /** * UPDATE template. * * @var string */ - protected $template_update = 'update [table_noalias] set [set] [where]'; + protected $template_update = '[with]update [table_noalias] set [set] [where]'; /** * TRUNCATE template. @@ -136,10 +136,7 @@ public function field($field, $alias = null) } foreach ($field as $alias => $f) { - if (is_numeric($alias)) { - $alias = null; - } - $this->field($f, $alias); + $this->field($f, is_numeric($alias) ? null : $alias); } return $this; @@ -345,6 +342,84 @@ protected function _render_from() /// }}} + // {{{ with() + + /** + * Specify WITH query to be used. + * + * @param Query $cursor Specifies cursor query or array [alias=>query] for adding multiple + * @param string $alias Specify alias for this cursor + * @param array $fields Optional array of field names used in cursor + * @param bool $recursive Is it recursive? + * + * @return $this + */ + public function with(self $cursor, string $alias, ?array $fields = null, bool $recursive = false) + { + // save cursor in args + $this->_set_args('with', $alias, [ + 'cursor' => $cursor, + 'fields' => $fields, + 'recursive' => $recursive, + ]); + + return $this; + } + + /** + * Recursive WITH query. + * + * @param Query|array $cursor Specifies cursor query or array [alias=>query] for adding multiple + * @param string $alias Specify alias for this cursor + * @param array $fields Optional array of field names used in cursor + * + * @return $this + */ + public function withRecursive(self $cursor, string $alias, ?array $fields = null) + { + return $this->with($cursor, $alias, $fields, true); + } + + /** + * Renders part of the template: [with] + * Do not call directly. + * + * @return string Parsed template chunk + */ + protected function _render_with() + { + // will be joined for output + $ret = []; + + if (empty($this->args['with'])) { + return ''; + } + + // process each defined cursor + $isRecursive = false; + foreach ($this->args['with'] as $alias => ['cursor'=>$cursor, 'fields'=>$fields, 'recursive'=>$recursive]) { + // cursor alias cannot be expression, so simply escape it + $s = $this->_escape($alias).' '; + + // set cursor fields + if ($fields !== null) { + $s .= '('.implode(',', array_map([$this, '_escape'], $fields)).') '; + } + + // will parameterize the value and escape if necessary + $s .= 'as '.$this->_consume($cursor, 'soft-escape'); + + // is at least one recursive ? + $isRecursive = $isRecursive || $recursive; + + $ret[] = $s; + } + + return 'with '.($isRecursive ? 'recursive ' : '').implode(',', $ret).' '; + } + + /// }}} + // {{{ join() /** @@ -403,12 +478,13 @@ public function join( } $j = []; - // try to find alias in foreign table definition + // try to find alias in foreign table definition. this behaviour should be deprecated if ($_foreign_alias === null) { list($foreign_table, $_foreign_alias) = array_pad(explode(' ', $foreign_table, 2), 2, null); } // Split and deduce fields + // NOTE that this will not allow table names with dots in there !!! list($f1, $f2) = array_pad(explode('.', $foreign_table, 2), 2, null); if (is_object($master_field)) { @@ -469,7 +545,7 @@ public function _render_join() $jj .= $j['t'].' join '; - $jj .= $this->_escape($j['f1']); + $jj .= $this->_escapeSoft($j['f1']); if ($j['fa'] !== null) { $jj .= ' as '.$this->_escape($j['fa']); diff --git a/src/Query_MySQL.php b/src/Query_MySQL.php index b4816710..34b41c60 100644 --- a/src/Query_MySQL.php +++ b/src/Query_MySQL.php @@ -21,7 +21,7 @@ class Query_MySQL extends Query protected $escape_char = '`'; /** @var string Expression classname */ - protected $expression_class = 'atk4\dsql\Expression_MySQL'; + protected $expression_class = Expression_MySQL::class; /** * UPDATE template. diff --git a/tests/QueryTest.php b/tests/QueryTest.php index 12c30c02..9e553340 100644 --- a/tests/QueryTest.php +++ b/tests/QueryTest.php @@ -1684,4 +1684,90 @@ public function testExprNow() ->render() ); } + + /** + * Test table name with dots in it - Select. + */ + public function testTableNameDot1() + { + // render table + $this->assertEquals( + '"foo"."bar"', + $this->callProtected($this->q()->table('foo.bar'), '_render_table') + ); + + $this->assertEquals( + '"foo"."bar" "a"', + $this->callProtected($this->q()->table('foo.bar', 'a'), '_render_table') + ); + + // where clause + $this->assertEquals( + 'select "name" from "db1"."employee" where "a" = :a', + $this->q() + ->field('name')->table('db1.employee')->where('a', 1) + ->render() + ); + + $this->assertEquals( + 'select "name" from "db1"."employee" where "db1"."employee"."a" = :a', + $this->q() + ->field('name')->table('db1.employee')->where('db1.employee.a', 1) + ->render() + ); + } + + /** + * Test WITH. + */ + public function testWith() + { + $q1 = $this->q()->table('salaries')->field('salary'); + + $q2 = $this->q() + ->with($q1, 'q1') + ->table('q1'); + $this->assertEquals('with "q1" as (select "salary" from "salaries") select * from "q1"', $q2->render()); + + $q2 = $this->q() + ->with($q1, 'q1', null, true) + ->table('q1'); + $this->assertEquals('with recursive "q1" as (select "salary" from "salaries") select * from "q1"', $q2->render()); + + $q2 = $this->q() + ->with($q1, 'q11', ['foo', 'qwe"ry']) + ->with($q1, 'q12', ['bar', 'baz'], true) // this one is recursive + ->table('q11') + ->table('q12'); + $this->assertEquals('with recursive "q11" ("foo","qwe""ry") as (select "salary" from "salaries"),"q12" ("bar","baz") as (select "salary" from "salaries") select * from "q11","q12"', $q2->render()); + + // now test some more useful reql life query + $quotes = $this->q() + ->table('quotes') + ->field('emp_id') + ->field($this->q()->expr('sum([])', ['total_net'])) + ->group('emp_id'); + $invoices = $this->q() + ->table('invoices') + ->field('emp_id') + ->field($this->q()->expr('sum([])', ['total_net'])) + ->group('emp_id'); + $q = $this->q() + ->with($quotes, 'q', ['emp', 'quoted']) + ->with($invoices, 'i', ['emp', 'invoiced']) + ->table('employees') + ->join('q.emp') + ->join('i.emp') + ->field(['name', 'salary', 'q.quoted', 'i.invoiced']); + $this->assertEquals( + 'with '. + '"q" ("emp","quoted") as (select "emp_id",sum(:a) from "quotes" group by "emp_id"),'. + '"i" ("emp","invoiced") as (select "emp_id",sum(:b) from "invoices" group by "emp_id") '. + 'select "name","salary","q"."quoted","i"."invoiced" '. + 'from "employees" '. + 'left join "q" on "q"."emp" = "employees"."id" '. + 'left join "i" on "i"."emp" = "employees"."id"', + $q->render() + ); + } } diff --git a/tests/RandomTest.php b/tests/RandomTest.php index b1d1fb81..0bc52089 100644 --- a/tests/RandomTest.php +++ b/tests/RandomTest.php @@ -23,7 +23,7 @@ public function q() public function testMiscInsert() { return $this->markTestIncomplete( - 'This test has not been implemented yet.' + 'This test has not been implemented yet.' ); $data = [ 'id' => null,