From: Andrey Kutejko Date: Sun, 17 Mar 2019 17:28:44 +0000 (+0100) Subject: merge route expressions and migrations X-Git-Url: https://git.andy128k.dev/?a=commitdiff_plain;h=3bd12477d7d27d514c0c61ede6e914c401f05713;p=ipf.git merge route expressions and migrations --- diff --git a/composer.json b/composer.json index ff8a3b0..0d10a2a 100644 --- a/composer.json +++ b/composer.json @@ -20,9 +20,7 @@ "doctrine/dbal": "v2.5.13", "d11wtq/boris": "dev-master", "andy128k/missing-tools": "0.2.*@dev", - "andy128k/migrations": "dev-master", "andy128k/pegp": "0.2", - "andy128k/routeexpression": "dev-master", "pimple/pimple": "~3.0", "twig/twig": "~1" }, diff --git a/composer.lock b/composer.lock index 8127526..384c5a3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,37 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "60b3f404429126af3c0dcaee85fcc316", + "content-hash": "73a01ff77b33bdec0cab728b7116b494", "packages": [ - { - "name": "andy128k/migrations", - "version": "dev-master", - "source": { - "type": "git", - "url": "git://git.andy128k.net/migrations.git", - "reference": "3d5a19ba8586db7e4405b3359c6918576a5260be" - }, - "require": { - "php": ">=5.3" - }, - "type": "library", - "autoload": { - "classmap": [ - "src/" - ] - }, - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Andrey Kutejko", - "email": "andy128k@gmail.com" - } - ], - "description": "Simple DB schema migrations", - "time": "2015-03-30T17:22:28+00:00" - }, { "name": "andy128k/missing-tools", "version": "0.2.x-dev", @@ -112,35 +83,6 @@ "description": "Library to construct PEG parsers", "time": "2019-03-17T13:12:33+00:00" }, - { - "name": "andy128k/routeexpression", - "version": "dev-master", - "source": { - "type": "git", - "url": "git://git.andy128k.net/routeexpression.git", - "reference": "0c5d0edce61a68fa27bc9b13278a06745ecdc5fe" - }, - "require-dev": { - "phpunit/phpunit": "4.4.*" - }, - "type": "library", - "autoload": { - "classmap": [ - "src" - ] - }, - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Andrey Kutejko", - "email": "andy128k@gmail.com" - } - ], - "description": "Route expression", - "time": "2015-01-11T10:36:51+00:00" - }, { "name": "d11wtq/boris", "version": "dev-master", @@ -2350,9 +2292,7 @@ "minimum-stability": "dev", "stability-flags": { "d11wtq/boris": 20, - "andy128k/missing-tools": 20, - "andy128k/migrations": 20, - "andy128k/routeexpression": 20 + "andy128k/missing-tools": 20 }, "prefer-stable": false, "prefer-lowest": false, diff --git a/doc/router.org b/doc/router.org new file mode 100644 index 0000000..1364d08 --- /dev/null +++ b/doc/router.org @@ -0,0 +1,57 @@ +* Route Expression + +** Build expression + + There are two ways to build expression: using fluent api and compiling. + +*** Fluent API + + Create instance of RouteExpression class and compose expression by chaining parts. + + Example: ~$expr = (new RouteExpression)->optional('/ru')->just('/story/');~ + + Available parts: + + 1. ~just($text)~ -- literal text; + 2. ~oneOf($choice1, $choice2, ...)~ -- one of given choices; + 3. ~oneOfv($choices)~ -- same as above, except it takes array of choices; + 4. ~optional($option)~ -- optional text, same as ~oneOf($option, '')~; + 5. ~id()~ -- nonempty sequence of digits; + 6. ~slug()~ -- nonempty sequence of letters, digits, underscores and dashes. + +*** Compilation + + Route expression can be compiled from text representation. + + Example: ~$expr = RouteExpression::compile('[/ru]/story/');~ + + Text enclosed in brackets ~[]~ is treated as ~optional~. + Text enclosed in braces ~{}~ is treated as ~oneOf~. Choices are separated by ~|~ pipe character. + Id and slug are written as ~:id~ and ~:slug~. + +** Matching + + #+BEGIN_SRC php + bool match($url, &$matches) + #+END_SRC + + This method returns ~true~ on successfull match of ~$url~ and matched parts in ~$matches~ array. + + Examples. + + ~RouteExpression::compile('[/ru]/story/')->match('/story/', $matches)~ returns ~true~ and ~$matches~ contains empty string which corresponds to optional part of expression. + + ~RouteExpression::compile('/article/:slug')->match('/article/php-is-awesome', $matches)~ returns ~true~ and ~$matches~ contains only string 'php-is-awesome'. + +** Reversing + + #+BEGIN_SRC php + string reverse($matches) + #+END_SRC + + This method substitutes values from ~$matches~ array to the expression and returns url. + + Example. + + ~RouteExpression::compile('/article/:slug')->reverse(array('/article/php-is-awesome')~ returns '/article/php-is-awesome'. + diff --git a/doc/template.org b/doc/template.org deleted file mode 100644 index f1fb697..0000000 --- a/doc/template.org +++ /dev/null @@ -1,31 +0,0 @@ -* Template - - This document describes template language of IPF framework. - - Templates are placed in 'templates' directories of project or application. - - =[ ]?= -- optional code - - =[ ]*= -- code can be repeated zero or more times. - -** Tags - -*** url - Syntax: ={url ''[, array([]*)]?}= - - Examples: ={url 'Content_View_Index'}=, ={url 'Catalog_View_Products', array($product->slug)}=, ={url 'News_View_Item', array($item->date, $item->slug)}= - - Reverse url for a given view and its parameters. - -*** sql - Syntax: ={sql}= - - Prints SQL statistics. - -** Configuration - - + =template_tags= -- additional template tags - + =template_modifiers= -- additional template modifiers - + =template_context_processors= -- list of context processors - + =debug= -- forces to recompile template before each rendering - diff --git a/ipf/admin/migrations/20140103000000_create_admin_log.php b/ipf/admin/migrations/20140103000000_create_admin_log.php index 5c84c6e..8a7563d 100644 --- a/ipf/admin/migrations/20140103000000_create_admin_log.php +++ b/ipf/admin/migrations/20140103000000_create_admin_log.php @@ -1,10 +1,10 @@ connection->exec('CREATE TABLE IF NOT EXISTS admin_log (' . + $connection->exec('CREATE TABLE IF NOT EXISTS admin_log (' . 'id BIGINT AUTO_INCREMENT,' . 'username VARCHAR(32),' . 'user_id BIGINT,' . @@ -18,4 +18,3 @@ class Migration_20140103000000 extends \PFF\Migrations\Migration 'DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = INNODB'); } } - diff --git a/ipf/auth/migrations/20140102000000_create_auth_tables.php b/ipf/auth/migrations/20140102000000_create_auth_tables.php index 4b53bb2..166590d 100644 --- a/ipf/auth/migrations/20140102000000_create_auth_tables.php +++ b/ipf/auth/migrations/20140102000000_create_auth_tables.php @@ -1,10 +1,10 @@ connection->exec('CREATE TABLE IF NOT EXISTS auth_user ('. + $connection->exec('CREATE TABLE IF NOT EXISTS auth_user ('. 'id BIGINT AUTO_INCREMENT,'. 'username VARCHAR(32) NOT NULL UNIQUE,'. 'password VARCHAR(128) NOT NULL,'. @@ -16,19 +16,19 @@ class Migration_20140102000000 extends \PFF\Migrations\Migration 'PRIMARY KEY(id)'. ') DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = INNODB'); - $this->connection->exec('CREATE TABLE IF NOT EXISTS auth_role ('. + $connection->exec('CREATE TABLE IF NOT EXISTS auth_role ('. 'id BIGINT AUTO_INCREMENT,'. 'name VARCHAR(255) UNIQUE,'. 'PRIMARY KEY(id)'. ') DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = INNODB'); - $this->connection->exec('CREATE TABLE IF NOT EXISTS auth_permission ('. + $connection->exec('CREATE TABLE IF NOT EXISTS auth_permission ('. 'id BIGINT AUTO_INCREMENT,'. 'name VARCHAR(255) NOT NULL UNIQUE,'. 'PRIMARY KEY(id)'. ') DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = INNODB'); - $this->connection->exec('CREATE TABLE IF NOT EXISTS auth_user_permission ('. + $connection->exec('CREATE TABLE IF NOT EXISTS auth_user_permission ('. 'user_id BIGINT,'. 'permission_id BIGINT,'. 'FOREIGN KEY(user_id) REFERENCES auth_user(id) ON DELETE CASCADE,'. @@ -36,7 +36,7 @@ class Migration_20140102000000 extends \PFF\Migrations\Migration 'PRIMARY KEY(user_id, permission_id)'. ') ENGINE = INNODB'); - $this->connection->exec('CREATE TABLE IF NOT EXISTS auth_user_role ('. + $connection->exec('CREATE TABLE IF NOT EXISTS auth_user_role ('. 'user_id BIGINT,'. 'role_id BIGINT,'. 'FOREIGN KEY(user_id) REFERENCES auth_user(id) ON DELETE CASCADE,'. @@ -44,7 +44,7 @@ class Migration_20140102000000 extends \PFF\Migrations\Migration 'PRIMARY KEY(user_id, role_id)'. ') ENGINE = INNODB'); - $this->connection->exec('CREATE TABLE IF NOT EXISTS auth_role_permission ('. + $connection->exec('CREATE TABLE IF NOT EXISTS auth_role_permission ('. 'role_id BIGINT,'. 'permission_id BIGINT,'. 'FOREIGN KEY(role_id) REFERENCES auth_role(id) ON DELETE CASCADE,'. @@ -53,4 +53,3 @@ class Migration_20140102000000 extends \PFF\Migrations\Migration ') ENGINE = INNODB'); } } - diff --git a/ipf/command/createmigration.php b/ipf/command/createmigration.php index e1f50c5..b2c586b 100644 --- a/ipf/command/createmigration.php +++ b/ipf/command/createmigration.php @@ -1,5 +1,7 @@ createMigration(IPF::get('project_root').'/project/db/migrations', implode('_', $args)); echo "Create new migration $filename\n"; } } - diff --git a/ipf/command/migrate.php b/ipf/command/migrate.php index 96acc96..edc060c 100644 --- a/ipf/command/migrate.php +++ b/ipf/command/migrate.php @@ -1,6 +1,7 @@ getPath() . 'migrations'; } - $m = new \PFF\Migrations\Migrations; + $m = new Migrations; $m->migrate($this->connection, $paths); } } diff --git a/ipf/migrations/migrations.php b/ipf/migrations/migrations.php new file mode 100644 index 0000000..5d671c1 --- /dev/null +++ b/ipf/migrations/migrations.php @@ -0,0 +1,89 @@ +ensureLogExists($connection); + + $files = array(); + foreach ($paths as $path) + $files = array_merge($files, glob($path.'/*.php')); + + usort($files, function($a, $b) { + return strcmp( + substr(basename($a), 0, 14), + substr(basename($b), 0, 14)); + }); + foreach ($files as $file) { + $ts = substr(basename($file), 0, 14); + + if ($this->migrationPassed($connection, $ts)) + continue; + + echo "Run migration $file\n"; + require_once $file; + + $connection->beginTransaction(); + try { + $migration = 'Migration_'.$ts; + $migration = new $migration; + $migration->migrate($connection); + + $this->markMigration($connection, $ts); + } catch (\Exception $e) { + $connection->rollback(); + throw $e; + } + $connection->commit(); + } + } + + public function createMigration($path, $hint='') + { + $ts = date('YmdHis'); + + $name = preg_replace('/[^a-z0-9]+/i', '_', $hint); + $name = preg_replace('/__+/', '_', $name); + $name = preg_replace('/^_/i', '', $name); + $name = preg_replace('/_$/i', '', $name); + if ($name) + $name = '_'.$name; + + if (!is_dir($path)) + mkdir($path, 0777, true); + + $filename = $path.'/'.$ts.$name.'.php'; + + $template = file_get_contents(dirname(__FILE__) . '/template'); + $content = str_replace('%TS%', $ts, $template); + file_put_contents($filename, $content); + + return $filename; + } + + private function ensureLogExists(Connection $connection) + { + $st = $connection->query("SHOW TABLES LIKE 'migration_log'"); + $exists = $st->fetch(); + $st->closeCursor(); + + if (!$exists) { + $connection->exec('CREATE TABLE migration_log (ts CHAR(14) NOT NULL PRIMARY KEY) ENGINE = INNODB'); + } + } + + private function migrationPassed(Connection $connection, $ts) + { + return (int)$connection->fetchColumn("SELECT COUNT(1) FROM migration_log WHERE ts = ?", [$ts]) > 0; + } + + private function markMigration(Connection $connection, $ts) + { + $connection->insert('migration_log', ['ts' => $ts]); + } +} diff --git a/ipf/migrations/template b/ipf/migrations/template new file mode 100644 index 0000000..bf957ce --- /dev/null +++ b/ipf/migrations/template @@ -0,0 +1,10 @@ +fetchAll('SELECT * FROM table WHERE param = ?', ['value']); + // $connection->exec('CREATE TABLE table (id BIGINT AUTO_INCREMENT, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = INNODB'); + } +} diff --git a/ipf/router.php b/ipf/router.php index 02f13ca..cd413ad 100644 --- a/ipf/router.php +++ b/ipf/router.php @@ -1,5 +1,7 @@ flattenRoutes($nested, $p, $route); } elseif (isset($route['regex'])) { - $this->routes[] = new IPF_Route($prefix . $route['regex'], 'IPF_Router_RegexMatch', $route); + $this->routes[] = new IPF_Route($prefix . $route['regex'], IPF_Router_RegexMatch::class, $route); } elseif (isset($route['expr'])) { - $this->routes[] = new IPF_Route($prefix . $route['expr'], 'RouteExpression', $route); + $this->routes[] = new IPF_Route($prefix . $route['expr'], Expression::class, $route); } else { throw new IPF_Exception('Unsupported route type'); } diff --git a/ipf/router/routeexpression.php b/ipf/router/routeexpression.php new file mode 100644 index 0000000..06d4859 --- /dev/null +++ b/ipf/router/routeexpression.php @@ -0,0 +1,204 @@ +expression = array(); + $this->regex = ''; + } + + public function just($text) + { + if ($text) { + $this->expression[] = array('text', $text); + $this->regex .= preg_quote($text, '#'); + } + return $this; + } + + public function oneOf() + { + return $this->oneOfv(func_get_args()); + } + + public function oneOfv($options) + { + $quoted = array(); + foreach ($options as $option) { + $quoted[] = preg_quote($option, '#'); + } + $this->regex .= '(' . implode('|', $quoted) . ')'; + + array_unshift($options, 'oneOf'); + $this->expression[] = $options; + return $this; + } + + public function optional($option) + { + $this->expression[] = array('optional', $option); + $this->regex .= '((?:' . preg_quote($option, '#') . ')?)'; + return $this; + } + + public function id() + { + $this->expression[] = array('id'); + $this->regex .= '(\d+)'; + return $this; + } + + public function slug() + { + $this->expression[] = array('slug'); + $this->regex .= '([\w\_\-]+)'; + return $this; + } + + public function match($url, &$matches) + { + if (1 == preg_match('#^'.$this->regex.'$#', $url, $matches)) { + array_shift($matches); + return true; + } else { + return false; + } + } + + public function reverse($matches) + { + $matchesIndex = 0; + $url = ''; + foreach ($this->expression as $part) { + switch ($part[0]) { + case 'text': + $url .= $part[1]; + break; + case 'oneOf': + $m = $matches[$matchesIndex++]; + $url .= $m; + break; + case 'optional': + $m = $matches[$matchesIndex++]; + if ($m && $m !== $part[1]) { + throw new \Exception('Wrong optional parameter "'.$m.'". "'.$part[1].'" is expected.'); + } + $url .= $m; + break; + case 'id': + $m = $matches[$matchesIndex++]; + $url .= $m; + break; + case 'slug': + $m = $matches[$matchesIndex++]; + $url .= $m; + break; + } + } + if ($matchesIndex !== count($matches)) { + throw new \Exception('Parameters doesn\'t match to url expression.'); + } + return $url; + } + + public function __toString() + { + $str = ''; + foreach ($this->expression as $part) { + switch ($part[0]) { + case 'text': + $str .= $part[1]; + break; + case 'oneOf': + $str .= '{' . implode('|', array_slice($part, 1)) . '}'; + break; + case 'optional': + $str .= '[' . $part[1] . ']'; + break; + case 'id': + $str .= ':id'; + break; + case 'slug': + $str .= ':slug'; + break; + } + } + return $str; + } + + public static function compile($expression) + { + $state = 0; + $text = ''; + $result = new self(); + for ($i = 0; $i < strlen($expression); ++$i) { + $ch = $expression[$i]; + switch ($state) { + case 0: + switch ($ch) { + case '{': + $result->just($text); + $state = 1; + $text = ''; + break; + case '[': + $result->just($text); + $state = 2; + $text = ''; + break; + case ':': + $result->just($text); + $state = 3; + $text = ''; + break; + default: + $text .= $ch; + break; + } + break; + case 1: + if ($ch === '}') { + $result->oneOfv(explode('|', $text)); + $state = 0; + $text = ''; + } else { + $text .= $ch; + } + break; + case 2: + if ($ch === ']') { + $result->optional($text); + $state = 0; + $text = ''; + } else { + $text .= $ch; + } + break; + case 3: + if (ctype_alpha($ch)) { + $text .= $ch; + } else { + if ($text === 'id') { + $result->id(); + } elseif ($text === 'slug') { + $result->slug(); + } else { + throw new \Exception('Unsupported route part "'.$text.'".'); + } + $state = 0; + $text = $ch; + } + break; + } + } + if ($state !== 0) + throw new \Exception('Unexpected end of route expression.'); + $result->just($text); + return $result; + } +} diff --git a/ipf/session/migrations/20140101000000_create_sessions.php b/ipf/session/migrations/20140101000000_create_sessions.php index 3132625..601efd4 100644 --- a/ipf/session/migrations/20140101000000_create_sessions.php +++ b/ipf/session/migrations/20140101000000_create_sessions.php @@ -1,10 +1,9 @@ connection->exec('CREATE TABLE IF NOT EXISTS session (session_key VARCHAR(40), data TEXT, updated_at TIMESTAMP, PRIMARY KEY(session_key)) ENGINE = INNODB'); + $connection->exec('CREATE TABLE IF NOT EXISTS session (session_key VARCHAR(40), data TEXT, updated_at TIMESTAMP, PRIMARY KEY(session_key)) ENGINE = INNODB'); } } - diff --git a/t/router/ExpressionTest.php b/t/router/ExpressionTest.php new file mode 100644 index 0000000..914159f --- /dev/null +++ b/t/router/ExpressionTest.php @@ -0,0 +1,96 @@ +match($url, $m); + $this->assertTrue($r); + if ($params !== null) + $this->assertEquals($m, $params); + } + + private function assertNotMatch(Expression $expression, $url) + { + $m = array(); + $r = $expression->match($url, $m); + $this->assertFalse($r); + } + + public function testOptional() + { + $re = new Expression; + $e = $re->optional('/ru')->just('/story/'); + $this->assertNotMatch($e, '/story'); + $this->assertMatch($e, '/story/', array('')); + $this->assertNotMatch($e, '/en/story/'); + $this->assertMatch($e, '/ru/story/', array('/ru')); + $this->assertNotMatch($e, '/ru/story'); + } + + public function testOptionalAndSlug() + { + $re = new Expression; + $e = $re->just('/')->optional('ru/')->just('story/')->slug()->just('/'); + $this->assertNotMatch($e, '/story/wrghsh'); + $this->assertMatch($e, '/story/srghwrghew/', array('', 'srghwrghew')); + $this->assertNotMatch($e, '/en/story/wgwrgewrghwrgh/'); + $this->assertMatch($e, '/ru/story/wrhwrhr4j4hee/', array('ru/', 'wrhwrhr4j4hee')); + $this->assertNotMatch($e, '/ru/story/rewewrhewrhe'); + } + + public function testOptionalAndId() + { + $re = new Expression; + $e = $re->just('/')->optional('ru/')->just('story/')->id()->just('/'); + $this->assertNotMatch($e, '/story/23'); + $this->assertMatch($e, '/story/354/', array('', 354)); + $this->assertMatch($e, '/ru/story/44/', array('ru/', 44)); + $this->assertNotMatch($e, '/ru/story/-34/'); + } + + public function testOneOf() + { + $re = new Expression; + $e = $re->oneOf('/ru', '/en')->just('/story/'); + $this->assertNotMatch($e, '/story/'); + $this->assertMatch($e, '/ru/story/', array('/ru')); + $this->assertMatch($e, '/en/story/', array('/en')); + $this->assertNotMatch($e, '/fr/story/'); + } + + public function testOneOfWithEmptyCase() + { + $re = new Expression; + $e = $re->oneOf('/ru', '/en', '')->just('/story/'); + $this->assertMatch($e, '/story/', array('')); + $this->assertMatch($e, '/ru/story/', array('/ru')); + $this->assertMatch($e, '/en/story/', array('/en')); + $this->assertNotMatch($e, '/fr/story/'); + } + + public function testCompiler() + { + $this->assertInstanceOf(Expression::class, Expression::compile('[/ru]/story/')); + $this->assertInstanceOf(Expression::class, Expression::compile('{/ru|/en}/story/')); + $this->assertInstanceOf(Expression::class, Expression::compile('{/ru|/en}/story/:slug/')); + $this->assertInstanceOf(Expression::class, Expression::compile('{/ru|/en}/story/:id/')); + } + + public function testReverseOptional() + { + $e = Expression::compile('[/ru]/story/'); + $this->assertEquals('/ru/story/', $e->reverse(array('/ru'))); + $this->assertEquals('/story/', $e->reverse(array(''))); + } + + public function testReverseOneOfAndId() + { + $e = Expression::compile('{/ru|/en}/story/:id/'); + $this->assertEquals('/ru/story/46536/', $e->reverse(array('/ru', 46536))); + $this->assertEquals('/en/story/15/', $e->reverse(array('/en', 0xF))); + } +}