diff --git a/docs/api/db/ActiveRecord.md b/docs/api/db/ActiveRecord.md index da281d8..822c548 100644 --- a/docs/api/db/ActiveRecord.md +++ b/docs/api/db/ActiveRecord.md @@ -300,7 +300,7 @@ foreach ($customers as $customer) { ~~~ How many SQL queries will be performed in the above code, assuming there are more than 100 customers in -the database? 101! The first SQL query brings back 100 customers. Then for each customer, another SQL query +the database? 101! The first SQL query brings back 100 customers. Then for each customer, a SQL query is performed to bring back the customer's orders. To solve the above performance problem, you can use the so-called *eager loading* by calling [[ActiveQuery::with()]]: @@ -318,7 +318,7 @@ foreach ($customers as $customer) { } ~~~ -As you can see, only two SQL queries were needed for the same task. +As you can see, only two SQL queries are needed for the same task. Sometimes, you may want to customize the relational queries on the fly. It can be diff --git a/framework/web/UrlManager.php b/framework/web/UrlManager.php index 4b9a606..e03f086 100644 --- a/framework/web/UrlManager.php +++ b/framework/web/UrlManager.php @@ -98,24 +98,50 @@ class UrlManager extends Component */ protected function processRules() { + foreach ($this->rules as $i => $rule) { + if (!isset($rule['class'])) { + $rule['class'] = 'yii\web\UrlRule'; + } + $this->rules[$i] = \Yii::createObject($rule); + } } /** * Parses the user request. - * @param HttpRequest $request the request application component + * @param Request $request the request application component * @return string the route (controllerID/actionID) and perhaps GET parameters in path format. */ public function parseUrl($request) { - if (isset($_GET[$this->routeVar])) { - return $_GET[$this->routeVar]; - } else { - return ''; + } + + public function createUrl($route, $params = array()) + { + $anchor = isset($params['#']) ? '#' . $params['#'] : ''; + unset($anchor['#']); + + /** @var $rule UrlRule */ + foreach ($this->rules as $rule) { + if (($url = $rule->createUrl($route, $params)) !== false) { + return $this->getBaseUrl() . $url . $anchor; + } } + + if ($params !== array()) { + $route .= '?' . http_build_query($params); + } + return $this->getBaseUrl() . '/' . $route . $anchor; } - public function createUrl($route, $params = array(), $ampersand = '&') + private $_baseUrl; + + public function getBaseUrl() { + return $this->_baseUrl; + } + public function setBaseUrl($value) + { + $this->_baseUrl = trim($value, '/'); } } diff --git a/framework/web/UrlRule.php b/framework/web/UrlRule.php index 1a4e77e..2961275 100644 --- a/framework/web/UrlRule.php +++ b/framework/web/UrlRule.php @@ -14,9 +14,15 @@ use yii\base\Object; /** * UrlManager manages the URLs of Yii applications. * array( - * 'pattern' => 'post/', + * 'pattern' => 'post/', * 'route' => 'post/view', - * 'params' => array('id' => 1), + * 'defaults' => array('page' => 1), + * ) + * + * array( + * 'pattern' => 'about', + * 'route' => 'site/page', + * 'defaults' => array('view' => 'about'), * ) * * @author Qiang Xue @@ -29,22 +35,128 @@ class UrlRule extends Object */ public $pattern; /** + * @var string the route to the controller action + */ + public $route; + /** * @var array the default GET parameters (name=>value) that this rule provides. * When this rule is used to parse the incoming request, the values declared in this property * will be injected into $_GET. */ - public $params = array(); - /** - * @var string the route to the controller action - */ - public $route; + public $defaults = array(); + + protected $paramRules = array(); + protected $routeRule; + protected $template; + protected $routeParams = array(); + + public function init() + { + $this->pattern = trim($this->pattern, '/'); + if ($this->pattern === '') { + $this->template = ''; + $this->pattern = '#^$#u'; + return; + } else { + $this->pattern = '/' . $this->pattern . '/'; + } + + $this->route = trim($this->route, '/'); + if (strpos($this->route, '<') !== false && preg_match_all('/<(\w+)>/', $this->route, $matches)) { + foreach ($matches[1] as $name) { + $this->routeParams[$name] = "<$name>"; + } + } + + $tr = $tr2 = array(); + if (preg_match_all('/<(\w+):?([^>]+)?>/', $this->pattern, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) { + foreach ($matches as $match) { + $name = $match[1][0]; + $pattern = isset($match[2][0]) ? $match[2][0] : '[^\/]+'; + if (isset($this->defaults[$name])) { + $length = strlen($match[0][0]); + $offset = $match[0][1]; + if ($this->pattern[$offset - 1] === '/' && $this->pattern[$offset + $length] === '/') { + $tr["<$name>"] = "(?P<$name>(?:/$pattern)?)"; + } else { + $tr["<$name>"] = "(?P<$name>(?:$pattern)?)"; + } + } else { + $tr["<$name>"] = "(?P<$name>$pattern)"; + } + if (isset($this->routeParams[$name])) { + $tr2["<$name>"] = "(?P<$name>$pattern)"; + } else { + $this->paramRules[$name] = $pattern === '[^\/]+' ? '' : "#^$pattern$#"; + } + } + } + + $this->template = preg_replace('/<(\w+):?([^>]+)?>/', '<$1>', $this->pattern); + $this->pattern = '#^' . strtr($this->template, $tr) . '$#u'; - public function createUrl($route, $params, $ampersand) + if ($this->routeParams !== array()) { + $this->routeRule = '#^' . strtr($this->route, $tr2) . '$#u'; + } + } + + public function parseUrl($pathInfo) { } - public function parse($path) + public function createUrl($route, $params) + { + $tr = array(); + + // match the route part first + if ($route !== $this->route) { + if ($this->routeRule !== null && preg_match($this->routeRule, $route, $matches)) { + foreach ($this->routeParams as $key => $name) { + $tr[$name] = $matches[$key]; + } + } else { + return false; + } + } + + // match default params + // if a default param is not in the route pattern, its value must also be matched + foreach ($this->defaults as $name => $value) { + if (!isset($params[$name])) { + return false; + } elseif (strcmp($params[$name], $value) === 0) { // strcmp will do string conversion automatically + unset($params[$name]); + if (isset($this->paramRules[$name])) { + $tr["<$name>"] = ''; + $tr["/<$name>/"] = '/'; + } + } elseif (!isset($this->paramRules[$name])) { + return false; + } + } + + // match params in the pattern + foreach ($this->paramRules as $name => $rule) { + if (isset($params[$name]) && ($rule === '' || preg_match($rule, $params[$name]))) { + $tr["<$name>"] = urlencode($params[$name]); + unset($params[$name]); + } elseif (!isset($this->defaults[$name]) || isset($params[$name])) { + return false; + } + } + + $url = trim(strtr($this->template, $tr), '/'); + if (strpos($url, '//') !== false) { + $url = preg_replace('#/+#', '/', $url); + } + if ($params !== array()) { + $url .= '?' . http_build_query($params); + } + return $url; + } + + public function parse($pathInfo) { $route = ''; $params = array(); diff --git a/tests/unit/framework/web/UrlManagerTest.php b/tests/unit/framework/web/UrlManagerTest.php new file mode 100644 index 0000000..3bd3007 --- /dev/null +++ b/tests/unit/framework/web/UrlManagerTest.php @@ -0,0 +1,7 @@ +getTestsForCreateUrl(); + foreach ($suites as $i => $suite) { + list ($name, $config, $tests) = $suite; + $rule = new UrlRule($config); + foreach ($tests as $j => $test) { + list ($route, $params, $expected) = $test; + $url = $rule->createUrl($route, $params); + $this->assertEquals($expected, $url, "Test#$i-$j: $name"); + } + } + } + + public function testParseUrl() + { + $suites = $this->getTestsForParseUrl(); + foreach ($suites as $i => $suite) { + list ($name, $config, $tests) = $suite; + $rule = new UrlRule($config); + foreach ($tests as $j => $test) { + $pathInfo = $test[0]; + $route = $test[1]; + $params = isset($test[2]) ? $test[2] : array(); + $result = $rule->parseUrl($pathInfo); + if ($route === false) { + $this->assertFalse($result, "Test#$i-$j: $name"); + } else { + $this->assertEquals(array($route, $params), $result, "Test#$i-$j: $name"); + } + } + } + } + + protected function getTestsForCreateUrl() + { + // structure of each test + // message for the test + // config for the URL rule + // list of inputs and outputs + // route + // params + // expected output + return array( + array( + 'empty pattern', + array( + 'pattern' => '', + 'route' => 'post/index', + ), + array( + array('post/index', array(), ''), + array('comment/index', array(), false), + array('post/index', array('page' => 1), '?page=1'), + ), + ), + array( + 'without param', + array( + 'pattern' => 'posts', + 'route' => 'post/index', + ), + array( + array('post/index', array(), 'posts'), + array('comment/index', array(), false), + array('post/index', array('page' => 1), 'posts?page=1'), + ), + ), + array( + 'with param', + array( + 'pattern' => 'post/', + 'route' => 'post/index', + ), + array( + array('post/index', array(), false), + array('comment/index', array(), false), + array('post/index', array('page' => 1), 'post/1'), + array('post/index', array('page' => 1, 'tag' => 'a'), 'post/1?tag=a'), + ), + ), + array( + 'with param requirement', + array( + 'pattern' => 'post/', + 'route' => 'post/index', + ), + array( + array('post/index', array('page' => 'abc'), false), + array('post/index', array('page' => 1), 'post/1'), + array('post/index', array('page' => 1, 'tag' => 'a'), 'post/1?tag=a'), + ), + ), + array( + 'with multiple params', + array( + 'pattern' => 'post/-', + 'route' => 'post/index', + ), + array( + array('post/index', array('page' => '1abc'), false), + array('post/index', array('page' => 1), false), + array('post/index', array('page' => 1, 'tag' => 'a'), 'post/1-a'), + ), + ), + array( + 'with optional param', + array( + 'pattern' => 'post//', + 'route' => 'post/index', + 'defaults' => array('page' => 1), + ), + array( + array('post/index', array('page' => 1), false), + array('post/index', array('page' => '1abc', 'tag' => 'a'), false), + array('post/index', array('page' => 1, 'tag' => 'a'), 'post/a'), + array('post/index', array('page' => 2, 'tag' => 'a'), 'post/2/a'), + ), + ), + array( + 'with optional param not in pattern', + array( + 'pattern' => 'post/', + 'route' => 'post/index', + 'defaults' => array('page' => 1), + ), + array( + array('post/index', array('page' => 1), false), + array('post/index', array('page' => '1abc', 'tag' => 'a'), false), + array('post/index', array('page' => 2, 'tag' => 'a'), false), + array('post/index', array('page' => 1, 'tag' => 'a'), 'post/a'), + ), + ), + array( + 'multiple optional params', + array( + 'pattern' => 'post///', + 'route' => 'post/index', + 'defaults' => array('page' => 1, 'sort' => 'yes'), + ), + array( + array('post/index', array('page' => 1), false), + array('post/index', array('page' => '1abc', 'tag' => 'a'), false), + array('post/index', array('page' => 1, 'tag' => 'a', 'sort' => 'YES'), false), + array('post/index', array('page' => 1, 'tag' => 'a', 'sort' => 'yes'), 'post/a'), + array('post/index', array('page' => 2, 'tag' => 'a', 'sort' => 'yes'), 'post/2/a'), + array('post/index', array('page' => 2, 'tag' => 'a', 'sort' => 'no'), 'post/2/a/no'), + array('post/index', array('page' => 1, 'tag' => 'a', 'sort' => 'no'), 'post/a/no'), + ), + ), + array( + 'optional param and required param separated by dashes', + array( + 'pattern' => 'post/-', + 'route' => 'post/index', + 'defaults' => array('page' => 1), + ), + array( + array('post/index', array('page' => 1), false), + array('post/index', array('page' => '1abc', 'tag' => 'a'), false), + array('post/index', array('page' => 1, 'tag' => 'a'), 'post/-a'), + array('post/index', array('page' => 2, 'tag' => 'a'), 'post/2-a'), + ), + ), + array( + 'optional param at the end', + array( + 'pattern' => 'post//', + 'route' => 'post/index', + 'defaults' => array('page' => 1), + ), + array( + array('post/index', array('page' => 1), false), + array('post/index', array('page' => '1abc', 'tag' => 'a'), false), + array('post/index', array('page' => 1, 'tag' => 'a'), 'post/a'), + array('post/index', array('page' => 2, 'tag' => 'a'), 'post/a/2'), + ), + ), + array( + 'consecutive optional params', + array( + 'pattern' => 'post//', + 'route' => 'post/index', + 'defaults' => array('page' => 1, 'tag' => 'a'), + ), + array( + array('post/index', array('page' => 1), false), + array('post/index', array('page' => '1abc', 'tag' => 'a'), false), + array('post/index', array('page' => 1, 'tag' => 'a'), 'post'), + array('post/index', array('page' => 2, 'tag' => 'a'), 'post/2'), + array('post/index', array('page' => 1, 'tag' => 'b'), 'post/b'), + array('post/index', array('page' => 2, 'tag' => 'b'), 'post/2/b'), + ), + ), + array( + 'consecutive optional params separated by dash', + array( + 'pattern' => 'post/-', + 'route' => 'post/index', + 'defaults' => array('page' => 1, 'tag' => 'a'), + ), + array( + array('post/index', array('page' => 1), false), + array('post/index', array('page' => '1abc', 'tag' => 'a'), false), + array('post/index', array('page' => 1, 'tag' => 'a'), 'post/-'), + array('post/index', array('page' => 1, 'tag' => 'b'), 'post/-b'), + array('post/index', array('page' => 2, 'tag' => 'a'), 'post/2-'), + array('post/index', array('page' => 2, 'tag' => 'b'), 'post/2-b'), + ), + ), + array( + 'route has parameters', + array( + 'pattern' => '/', + 'route' => '/', + 'defaults' => array(), + ), + array( + array('post/index', array('page' => 1), 'post/index?page=1'), + array('module/post/index', array(), false), + ), + ), + array( + 'route has parameters with regex', + array( + 'pattern' => '/', + 'route' => '/', + 'defaults' => array(), + ), + array( + array('post/index', array('page' => 1), 'post/index?page=1'), + array('comment/index', array('page' => 1), 'comment/index?page=1'), + array('test/index', array('page' => 1), false), + array('post', array(), false), + array('module/post/index', array(), false), + array('post/index', array('controller' => 'comment'), 'post/index?controller=comment'), + ), + ), + /* this is not supported + array( + 'route has default parameter', + array( + 'pattern' => '/', + 'route' => '/', + 'defaults' => array('action' => 'index'), + ), + array( + array('post/view', array('page' => 1), 'post/view?page=1'), + array('comment/view', array('page' => 1), 'comment/view?page=1'), + array('test/view', array('page' => 1), false), + array('post/index', array('page' => 1), 'post?page=1'), + ), + ), + */ + ); + } + + protected function getTestsForParseUrl() + { + // structure of each test + // message for the test + // config for the URL rule + // list of inputs and outputs + // pathInfo + // expected route, or false if the rule doesn't apply + // expected params, or not set if empty + return array( + array( + 'empty pattern', + array( + 'pattern' => '', + 'route' => 'post/index', + ), + array( + array('', 'post/index'), + array('a', false), + ), + ), + array( + 'without param', + array( + 'pattern' => 'posts', + 'route' => 'post/index', + ), + array( + array('posts', 'post/index'), + array('a', false), + ), + ), + array( + 'with param', + array( + 'pattern' => 'post/', + 'route' => 'post/index', + ), + array( + array('post/1', 'post/index', array('page' => '1')), + array('post/a', 'post/index', array('page' => 'a')), + array('post', false), + array('posts', false), + ), + ), + array( + 'with param requirement', + array( + 'pattern' => 'post/', + 'route' => 'post/index', + ), + array( + array('post/1', 'post/index', array('page' => '1')), + array('post/a', false), + array('post/1/a', false), + ), + ), + array( + 'with multiple params', + array( + 'pattern' => 'post/-', + 'route' => 'post/index', + ), + array( + array('post/1-a', 'post/index', array('page' => '1', 'tag' => 'a')), + array('post/a', false), + array('post/1', false), + array('post/1/a', false), + ), + ), + array( + 'with optional param', + array( + 'pattern' => 'post//', + 'route' => 'post/index', + 'defaults' => array('page' => 1), + ), + array( + array('post/1/a', 'post/index', array('page' => '1', 'tag' => 'a')), + array('post/2/a', 'post/index', array('page' => '2', 'tag' => 'a')), + array('post/a', 'post/index', array('page' => '1', 'tag' => 'a')), + array('post/1', 'post/index', array('page' => '1', 'tag' => '1')), + ), + ), + array( + 'with optional param not in pattern', + array( + 'pattern' => 'post/', + 'route' => 'post/index', + 'defaults' => array('page' => 1), + ), + array( + array('post/a', 'post/index', array('page' => '1', 'tag' => 'a')), + array('post/1', 'post/index', array('page' => '1', 'tag' => '1')), + array('post', false), + ), + ), + array( + 'multiple optional params', + array( + 'pattern' => 'post///', + 'route' => 'post/index', + 'defaults' => array('page' => 1, 'sort' => 'yes'), + ), + array( + array('post/1/a/yes', 'post/index', array('page' => '1', 'tag' => 'a', 'sort' => 'yes')), + array('post/2/a/no', 'post/index', array('page' => '2', 'tag' => 'a', 'sort' => 'no')), + array('post/2/a', 'post/index', array('page' => '2', 'tag' => 'a', 'sort' => 'yes')), + array('post/a/no', 'post/index', array('page' => '1', 'tag' => 'a', 'sort' => 'no')), + array('post/a', 'post/index', array('page' => '1', 'tag' => 'a', 'sort' => 'yes')), + array('post', false), + ), + ), + array( + 'optional param and required param separated by dashes', + array( + 'pattern' => 'post/-', + 'route' => 'post/index', + 'defaults' => array('page' => 1), + ), + array( + array('post/1-a', 'post/index', array('page' => '1', 'tag' => 'a')), + array('post/2-a', 'post/index', array('page' => '2', 'tag' => 'a')), + array('post/-a', 'post/index', array('page' => '1', 'tag' => 'a')), + array('post/a', false), + array('post-a', false), + ), + ), + array( + 'optional param at the end', + array( + 'pattern' => 'post//', + 'route' => 'post/index', + 'defaults' => array('page' => 1), + ), + array( + array('post/a/1', 'post/index', array('page' => '1', 'tag' => 'a')), + array('post/a/2', 'post/index', array('page' => '2', 'tag' => 'a')), + array('post/a', 'post/index', array('page' => '1', 'tag' => 'a')), + array('post/2', 'post/index', array('page' => '1', 'tag' => '2')), + array('post', false), + ), + ), + array( + 'consecutive optional params', + array( + 'pattern' => 'post//', + 'route' => 'post/index', + 'defaults' => array('page' => 1, 'tag' => 'a'), + ), + array( + array('post/2/b', 'post/index', array('page' => '2', 'tag' => 'b')), + array('post/2', 'post/index', array('page' => '2', 'tag' => 'a')), + array('post', 'post/index', array('page' => '1', 'tag' => 'a')), + array('post/b', 'post/index', array('page' => '1', 'tag' => 'b')), + array('post//b', false), + ), + ), + array( + 'consecutive optional params separated by dash', + array( + 'pattern' => 'post/-', + 'route' => 'post/index', + 'defaults' => array('page' => 1, 'tag' => 'a'), + ), + array( + array('post/2-b', 'post/index', array('page' => '2', 'tag' => 'b')), + array('post/2-', 'post/index', array('page' => '2', 'tag' => 'a')), + array('post/-b', 'post/index', array('page' => '1', 'tag' => 'b')), + array('post/-', 'post/index', array('page' => '1', 'tag' => 'a')), + array('post', false), + ), + ), + ); + } +}