]> git.andy128k.dev Git - ipf.git/commitdiff
merge route expressions and migrations
authorAndrey Kutejko <andy128k@gmail.com>
Sun, 17 Mar 2019 17:28:44 +0000 (18:28 +0100)
committerAndrey Kutejko <andy128k@gmail.com>
Sun, 17 Mar 2019 17:48:18 +0000 (18:48 +0100)
14 files changed:
composer.json
composer.lock
doc/router.org [new file with mode: 0644]
doc/template.org [deleted file]
ipf/admin/migrations/20140103000000_create_admin_log.php
ipf/auth/migrations/20140102000000_create_auth_tables.php
ipf/command/createmigration.php
ipf/command/migrate.php
ipf/migrations/migrations.php [new file with mode: 0644]
ipf/migrations/template [new file with mode: 0644]
ipf/router.php
ipf/router/routeexpression.php [new file with mode: 0644]
ipf/session/migrations/20140101000000_create_sessions.php
t/router/ExpressionTest.php [new file with mode: 0644]

index ff8a3b001cf07e4b19cd4149e4dcfc713dda551b..0d10a2abe41d24913dd13881924962f3c7773df4 100644 (file)
@@ -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"
   },
index 81275262fbe9e35c784c8446b27cb88d7ff5c906..384c5a30e07c4447b746e2a8c282f14edd4b2225 100644 (file)
@@ -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",
             "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",
     "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 (file)
index 0000000..1364d08
--- /dev/null
@@ -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 (file)
index f1fb697..0000000
+++ /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 '<view-name>'[, array([<arg>]*)]?}=
-
-    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
-
index 5c84c6ec8a5b0129bc5f22578ae1e5a0602e969c..8a7563da9146d13d829806aeb31a42f8a20f8982 100644 (file)
@@ -1,10 +1,10 @@
 <?php
 
-class Migration_20140103000000 extends \PFF\Migrations\Migration
+class Migration_20140103000000
 {
-    function migrate()
+    function migrate(\Doctrine\DBAL\Connection $connection)
     {
-        $this->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');
     }
 }
-
index 4b53bb229d082834c88c89162447d2b8fb034700..166590dd57e47c9cd36f5a0fd8900760a826e14e 100644 (file)
@@ -1,10 +1,10 @@
 <?php
 
-class Migration_20140102000000 extends \PFF\Migrations\Migration
+class Migration_20140102000000
 {
-    function migrate()
+    function migrate(\Doctrine\DBAL\Connection $connection)
     {
-        $this->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');
     }
 }
-
index e1f50c575ac8046c08353bb194ba7da7e50e408b..b2c586bbaf198a498c33a9f48885a3273094004e 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+use IPF\Migrations\Migrations;
+
 class IPF_Command_CreateMigration
 {
     public $command = 'createmigration';
@@ -7,9 +9,8 @@ class IPF_Command_CreateMigration
 
     public function run($args=null)
     {
-        $m = new \PFF\Migrations\Migrations;
+        $m = new Migrations;
         $filename = $m->createMigration(IPF::get('project_root').'/project/db/migrations', implode('_', $args));
         echo "Create new migration $filename\n";
     }
 }
-
index 96acc9699347b073c31a10926cd0efabf8e1af69..edc060c52017fe03f138610dcd1a533d7d35223d 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 
 use Doctrine\DBAL\Connection;
+use IPF\Migrations\Migrations;
 
 class IPF_Command_Migrate
 {
@@ -25,7 +26,7 @@ class IPF_Command_Migrate
             $paths[] = $app->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 (file)
index 0000000..5d671c1
--- /dev/null
@@ -0,0 +1,89 @@
+<?php
+
+namespace IPF\Migrations;
+
+use Doctrine\DBAL\Connection;
+
+class Migrations
+{
+    public function migrate(Connection $connection, array $paths)
+    {
+        $this->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 (file)
index 0000000..bf957ce
--- /dev/null
@@ -0,0 +1,10 @@
+<?php
+
+class Migration_%TS%
+{
+    function migrate(\Doctrine\DBAL\Connection $connection)
+    {
+        // $data = $connection->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');
+    }
+}
index 02f13cafa7a0c9f5ee0c48fb3fbf797700ecf653..cd413ad59f1e8fffba4f9031e3482241a3336fce 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+use IPF\Router\Expression;
+
 class IPF_Router
 {
     /** @var IPF_Route[] */
@@ -23,9 +25,9 @@ class IPF_Router
                 $p = $prefix . \PFF\Arr::pop($route, 'prefix', '');
                 $this->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 (file)
index 0000000..06d4859
--- /dev/null
@@ -0,0 +1,204 @@
+<?php
+
+namespace IPF\Router;
+
+final class Expression
+{
+    private $expression, $regex;
+
+    public function __construct()
+    {
+        $this->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;
+    }
+}
index 3132625d9b158b7ea15ad366e0aee88f1108763d..601efd4989488cabadc2441737998a458de33f8c 100644 (file)
@@ -1,10 +1,9 @@
 <?php
 
-class Migration_20140101000000 extends \PFF\Migrations\Migration
+class Migration_20140101000000
 {
-    function migrate()
+    function migrate(\Doctrine\DBAL\Connection $connection)
     {
-        $this->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 (file)
index 0000000..914159f
--- /dev/null
@@ -0,0 +1,96 @@
+<?php
+
+use IPF\Router\Expression;
+
+class RouteExpressionTest extends PHPUnit_Framework_TestCase
+{
+    private function assertMatch(Expression $expression, $url, $params=null)
+    {
+        $m = array();
+        $r = $expression->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)));
+    }
+}