diff --git a/admin/framework/composer.json b/admin/framework/composer.json
index 248eea0c96cc..2f3022a92b2b 100644
--- a/admin/framework/composer.json
+++ b/admin/framework/composer.json
@@ -13,7 +13,7 @@
"psr/log": "^1.1"
},
"require-dev": {
- "kint-php/kint": "^5.0.1",
+ "kint-php/kint": "^5.0.3",
"codeigniter/coding-standard": "^1.5",
"fakerphp/faker": "^1.9",
"friendsofphp/php-cs-fixer": "3.13.0",
diff --git a/composer.json b/composer.json
index 1fbc0fff4537..8a2f047409f0 100644
--- a/composer.json
+++ b/composer.json
@@ -13,7 +13,7 @@
"psr/log": "^1.1"
},
"require-dev": {
- "kint-php/kint": "^5.0.1",
+ "kint-php/kint": "^5.0.3",
"codeigniter/coding-standard": "^1.5",
"fakerphp/faker": "^1.9",
"friendsofphp/php-cs-fixer": "3.13.0",
@@ -25,7 +25,7 @@
"phpunit/phpcov": "^8.2",
"phpunit/phpunit": "^9.1",
"predis/predis": "^1.1 || ^2.0",
- "rector/rector": "0.15.11",
+ "rector/rector": "0.15.12",
"vimeo/psalm": "^5.0"
},
"suggest": {
diff --git a/contributing/signing.md b/contributing/signing.md
index e925327e9b71..6626911591d2 100644
--- a/contributing/signing.md
+++ b/contributing/signing.md
@@ -53,3 +53,7 @@ bash shell to use the **-S** option to force the secure signing.
Regardless of how you sign a commit, commit messages are important too.
See [Contribution Workflow](./workflow.md#commit-messages) for details.
+
+## GPG-Signing Old Commits
+
+See [Contribution Workflow](./workflow.md#gpg-signing-old-commits).
diff --git a/phpstan-baseline.neon.dist b/phpstan-baseline.neon.dist
index 4259df0cc3ef..2d1f9ddd3c41 100644
--- a/phpstan-baseline.neon.dist
+++ b/phpstan-baseline.neon.dist
@@ -217,7 +217,7 @@ parameters:
-
message: "#^Call to an undefined method CodeIgniter\\\\Router\\\\RouteCollectionInterface\\:\\:getDefaultNamespace\\(\\)\\.$#"
- count: 3
+ count: 2
path: system/Router/Router.php
-
diff --git a/system/Debug/Exceptions.php b/system/Debug/Exceptions.php
index bcce589db894..6dc098d4af10 100644
--- a/system/Debug/Exceptions.php
+++ b/system/Debug/Exceptions.php
@@ -68,6 +68,8 @@ class Exceptions
*/
protected $response;
+ private ?Throwable $exceptionCaughtByExceptionHandler = null;
+
/**
* @param CLIRequest|IncomingRequest $request
*/
@@ -113,6 +115,8 @@ public function initialize()
*/
public function exceptionHandler(Throwable $exception)
{
+ $this->exceptionCaughtByExceptionHandler = $exception;
+
[$statusCode, $exitCode] = $this->determineCodes($exception);
if ($this->config->log === true && ! in_array($statusCode, $this->config->ignoreCodes, true)) {
@@ -191,6 +195,13 @@ public function shutdownHandler()
['type' => $type, 'message' => $message, 'file' => $file, 'line' => $line] = $error;
+ if ($this->exceptionCaughtByExceptionHandler) {
+ $message .= "\n【Previous Exception】\n"
+ . get_class($this->exceptionCaughtByExceptionHandler) . "\n"
+ . $this->exceptionCaughtByExceptionHandler->getMessage() . "\n"
+ . $this->exceptionCaughtByExceptionHandler->getTraceAsString();
+ }
+
if (in_array($type, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true)) {
$this->exceptionHandler(new ErrorException($message, 0, $type, $file, $line));
}
diff --git a/system/HTTP/IncomingRequest.php b/system/HTTP/IncomingRequest.php
index 3304f4664a76..a3a8de837759 100755
--- a/system/HTTP/IncomingRequest.php
+++ b/system/HTTP/IncomingRequest.php
@@ -329,7 +329,7 @@ protected function parseQueryString(): string
$uri = $_SERVER['QUERY_STRING'] ?? @getenv('QUERY_STRING');
if (trim($uri, '/') === '') {
- return '';
+ return '/';
}
if (strncmp($uri, '/', 1) === 0) {
diff --git a/system/Helpers/form_helper.php b/system/Helpers/form_helper.php
index 57d3f38b63a4..dfd4a64f0669 100644
--- a/system/Helpers/form_helper.php
+++ b/system/Helpers/form_helper.php
@@ -741,9 +741,12 @@ function validation_show_error(string $field, string $template = 'single'): stri
$config = config('Validation');
$view = Services::renderer();
- $errors = validation_errors();
+ $errors = array_filter(validation_errors(), static fn ($key) => preg_match(
+ '/^' . str_replace(['\.\*', '\*\.'], ['\..+', '.+\.'], preg_quote($field, '/')) . '$/',
+ $key
+ ), ARRAY_FILTER_USE_KEY);
- if (! array_key_exists($field, $errors)) {
+ if ($errors === []) {
return '';
}
@@ -751,7 +754,7 @@ function validation_show_error(string $field, string $template = 'single'): stri
throw ValidationException::forInvalidTemplate($template);
}
- return $view->setVar('error', $errors[$field])
+ return $view->setVar('error', implode("\n", $errors))
->render($config->templates[$template]);
}
}
diff --git a/system/Router/Router.php b/system/Router/Router.php
index 137aa4e43af9..8dd35c656e39 100644
--- a/system/Router/Router.php
+++ b/system/Router/Router.php
@@ -155,6 +155,10 @@ public function __construct(RouteCollectionInterface $routes, ?Request $request
}
/**
+ * Finds the controller method corresponding to the URI.
+ *
+ * @param string|null $uri URI path relative to baseURL
+ *
* @return Closure|string Controller classname or Closure
*
* @throws PageNotFoundException
@@ -162,12 +166,9 @@ public function __construct(RouteCollectionInterface $routes, ?Request $request
*/
public function handle(?string $uri = null)
{
- // If we cannot find a URI to match against, then
- // everything runs off of its default settings.
+ // If we cannot find a URI to match against, then set it to root (`/`).
if ($uri === null || $uri === '') {
- return strpos($this->controller, '\\') === false
- ? $this->collection->getDefaultNamespace() . $this->controller
- : $this->controller;
+ $uri = '/';
}
// Decode URL-encoded string
diff --git a/system/Router/RouterInterface.php b/system/Router/RouterInterface.php
index 1efeb2e2c6e2..ffed59ca8aac 100644
--- a/system/Router/RouterInterface.php
+++ b/system/Router/RouterInterface.php
@@ -27,7 +27,7 @@ public function __construct(RouteCollectionInterface $routes, ?Request $request
/**
* Finds the controller method corresponding to the URI.
*
- * @param string $uri
+ * @param string|null $uri URI path relative to baseURL
*
* @return Closure|string Controller classname or Closure
*/
diff --git a/system/ThirdParty/Kint/Utils.php b/system/ThirdParty/Kint/Utils.php
index 1394b0819095..b51b33558501 100644
--- a/system/ThirdParty/Kint/Utils.php
+++ b/system/ThirdParty/Kint/Utils.php
@@ -118,7 +118,9 @@ public static function composerGetExtras(string $key = 'kint'): array
continue;
}
- foreach ($packages as $package) {
+ // Composer 2.0 Compatibility: packages are now wrapped into a "packages" top level key instead of the whole file being the package array
+ // @see https://getcomposer.org/upgrade/UPGRADE-2.0.md
+ foreach ($packages['packages'] ?? $packages as $package) {
if (isset($package['extra'][$key]) && \is_array($package['extra'][$key])) {
$extras = \array_replace($extras, $package['extra'][$key]);
}
diff --git a/tests/system/HTTP/IncomingRequestDetectingTest.php b/tests/system/HTTP/IncomingRequestDetectingTest.php
index ab0bfb5c6092..cd220cbe0987 100644
--- a/tests/system/HTTP/IncomingRequestDetectingTest.php
+++ b/tests/system/HTTP/IncomingRequestDetectingTest.php
@@ -31,127 +31,158 @@ protected function setUp(): void
$_POST = $_GET = $_SERVER = $_REQUEST = $_ENV = $_COOKIE = $_SESSION = [];
- $origin = 'http://www.example.com/index.php/woot?code=good#pos';
-
+ // The URI object is not used in detectPath().
+ $origin = 'http://www.example.com/index.php/woot?code=good#pos';
$this->request = new IncomingRequest(new App(), new URI($origin), null, new UserAgent());
}
public function testPathDefault()
{
- $this->request->uri = '/index.php/woot?code=good#pos';
+ // /index.php/woot?code=good#pos
$_SERVER['REQUEST_URI'] = '/index.php/woot';
$_SERVER['SCRIPT_NAME'] = '/index.php';
- $expected = 'woot';
+
+ $expected = 'woot';
$this->assertSame($expected, $this->request->detectPath());
}
- public function testPathEmpty()
+ public function testPathDefaultEmpty()
{
- $this->request->uri = '/';
+ // /
$_SERVER['REQUEST_URI'] = '/';
$_SERVER['SCRIPT_NAME'] = '/index.php';
- $expected = '/';
+
+ $expected = '/';
$this->assertSame($expected, $this->request->detectPath());
}
public function testPathRequestURI()
{
- $this->request->uri = '/index.php/woot?code=good#pos';
+ // /index.php/woot?code=good#pos
$_SERVER['REQUEST_URI'] = '/index.php/woot';
$_SERVER['SCRIPT_NAME'] = '/index.php';
- $expected = 'woot';
+
+ $expected = 'woot';
$this->assertSame($expected, $this->request->detectPath('REQUEST_URI'));
}
public function testPathRequestURINested()
{
- $this->request->uri = '/ci/index.php/woot?code=good#pos';
+ // I'm not sure but this is a case of Apache config making such SERVER
+ // values?
+ // The current implementation doesn't use the value of the URI object.
+ // So I removed the code to set URI. Therefore, it's exactly the same as
+ // the method above as a test.
+ // But it may be changed in the future to use the value of the URI object.
+ // So I don't remove this test case.
+
+ // /ci/index.php/woot?code=good#pos
$_SERVER['REQUEST_URI'] = '/index.php/woot';
$_SERVER['SCRIPT_NAME'] = '/index.php';
- $expected = 'woot';
+
+ $expected = 'woot';
$this->assertSame($expected, $this->request->detectPath('REQUEST_URI'));
}
public function testPathRequestURISubfolder()
{
- $this->request->uri = '/ci/index.php/popcorn/woot?code=good#pos';
+ // /ci/index.php/popcorn/woot?code=good#pos
$_SERVER['REQUEST_URI'] = '/ci/index.php/popcorn/woot';
$_SERVER['SCRIPT_NAME'] = '/ci/index.php';
- $expected = 'popcorn/woot';
+
+ $expected = 'popcorn/woot';
$this->assertSame($expected, $this->request->detectPath('REQUEST_URI'));
}
public function testPathRequestURINoIndex()
{
- $this->request->uri = '/sub/example';
+ // /sub/example
$_SERVER['REQUEST_URI'] = '/sub/example';
$_SERVER['SCRIPT_NAME'] = '/sub/index.php';
- $expected = 'example';
+
+ $expected = 'example';
$this->assertSame($expected, $this->request->detectPath('REQUEST_URI'));
}
public function testPathRequestURINginx()
{
- $this->request->uri = '/ci/index.php/woot?code=good#pos';
+ // /ci/index.php/woot?code=good#pos
$_SERVER['REQUEST_URI'] = '/index.php/woot?code=good';
$_SERVER['SCRIPT_NAME'] = '/index.php';
- $expected = 'woot';
+
+ $expected = 'woot';
$this->assertSame($expected, $this->request->detectPath('REQUEST_URI'));
}
public function testPathRequestURINginxRedirecting()
{
- $this->request->uri = '/?/ci/index.php/woot';
+ // /?/ci/index.php/woot
$_SERVER['REQUEST_URI'] = '/?/ci/woot';
$_SERVER['SCRIPT_NAME'] = '/index.php';
- $expected = 'ci/woot';
+
+ $expected = 'ci/woot';
$this->assertSame($expected, $this->request->detectPath('REQUEST_URI'));
}
public function testPathRequestURISuppressed()
{
- $this->request->uri = '/woot?code=good#pos';
+ // /woot?code=good#pos
$_SERVER['REQUEST_URI'] = '/woot';
$_SERVER['SCRIPT_NAME'] = '/';
- $expected = 'woot';
+
+ $expected = 'woot';
$this->assertSame($expected, $this->request->detectPath('REQUEST_URI'));
}
public function testPathQueryString()
{
- $this->request->uri = '/?/ci/index.php/woot';
- $_SERVER['REQUEST_URI'] = '/?/ci/woot';
+ // /index.php?/ci/woot
+ $_SERVER['REQUEST_URI'] = '/index.php?/ci/woot';
$_SERVER['QUERY_STRING'] = '/ci/woot';
$_SERVER['SCRIPT_NAME'] = '/index.php';
- $expected = 'ci/woot';
+
+ $expected = 'ci/woot';
+ $this->assertSame($expected, $this->request->detectPath('QUERY_STRING'));
+ }
+
+ public function testPathQueryStringWithQueryString()
+ {
+ // /index.php?/ci/woot?code=good#pos
+ $_SERVER['REQUEST_URI'] = '/index.php?/ci/woot?code=good';
+ $_SERVER['QUERY_STRING'] = '/ci/woot?code=good';
+ $_SERVER['SCRIPT_NAME'] = '/index.php';
+
+ $expected = 'ci/woot';
$this->assertSame($expected, $this->request->detectPath('QUERY_STRING'));
}
public function testPathQueryStringEmpty()
{
- $this->request->uri = '/?/ci/index.php/woot';
- $_SERVER['REQUEST_URI'] = '/?/ci/woot';
+ // /index.php?
+ $_SERVER['REQUEST_URI'] = '/index.php?';
$_SERVER['QUERY_STRING'] = '';
$_SERVER['SCRIPT_NAME'] = '/index.php';
- $expected = '';
+
+ $expected = '/';
$this->assertSame($expected, $this->request->detectPath('QUERY_STRING'));
}
public function testPathPathInfo()
{
- $this->request->uri = '/index.php/woot?code=good#pos';
+ // /index.php/woot?code=good#pos
$this->request->setGlobal('server', [
'PATH_INFO' => null,
]);
$_SERVER['REQUEST_URI'] = '/index.php/woot';
$_SERVER['SCRIPT_NAME'] = '/index.php';
- $expected = 'woot';
+
+ $expected = 'woot';
$this->assertSame($expected, $this->request->detectPath('PATH_INFO'));
}
public function testPathPathInfoGlobal()
{
- $this->request->uri = '/index.php/woot?code=good#pos';
+ // /index.php/woot?code=good#pos
$this->request->setGlobal('server', [
'PATH_INFO' => 'silliness',
]);
diff --git a/tests/system/Helpers/FormHelperTest.php b/tests/system/Helpers/FormHelperTest.php
index 4e1ea582dd2d..9a525adf7ad2 100644
--- a/tests/system/Helpers/FormHelperTest.php
+++ b/tests/system/Helpers/FormHelperTest.php
@@ -996,6 +996,25 @@ public function testValidationShowError()
$this->assertSame('The ID field is required.' . "\n", $html);
}
+ public function testValidationShowErrorForWildcards()
+ {
+ $validation = Services::validation();
+ $validation->setRule('user.*.name', 'Name', 'required')
+ ->run([
+ 'user' => [
+ 'friends' => [
+ ['name' => 'Name1'],
+ ['name' => ''],
+ ['name' => 'Name2'],
+ ],
+ ],
+ ]);
+
+ $html = validation_show_error('user.*.name');
+
+ $this->assertSame('The Name field is required.' . "\n", $html);
+ }
+
public function testFormParseFormAttributesTrue()
{
$expected = 'readonly ';
diff --git a/tests/system/Router/RouterTest.php b/tests/system/Router/RouterTest.php
index 5e7733ee4ff4..5be01a47bfc6 100644
--- a/tests/system/Router/RouterTest.php
+++ b/tests/system/Router/RouterTest.php
@@ -39,6 +39,7 @@ protected function setUp(): void
$this->collection = new RouteCollection(Services::locator(), $moduleConfig);
$routes = [
+ '/' => 'Home::index',
'users' => 'Users::index',
'user-setting/show-list' => 'User_setting::show_list',
'user-setting/(:segment)' => 'User_setting::detail/$1',
@@ -56,20 +57,20 @@ protected function setUp(): void
'objects/(:segment)/sort/(:segment)/([A-Z]{3,7})' => 'AdminList::objectsSortCreate/$1/$2/$3',
'(:segment)/(:segment)/(:segment)' => '$2::$3/$1',
];
-
$this->collection->map($routes);
+
$this->request = Services::request();
$this->request->setMethod('get');
}
- public function testEmptyURIMatchesDefaults()
+ public function testEmptyURIMatchesRoot()
{
$router = new Router($this->collection, $this->request);
$router->handle('');
- $this->assertSame($this->collection->getDefaultController(), $router->controllerName());
- $this->assertSame($this->collection->getDefaultMethod(), $router->methodName());
+ $this->assertSame('\Home', $router->controllerName());
+ $this->assertSame('index', $router->methodName());
}
public function testZeroAsURIPath()
diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst
index 709e55838941..56035153db80 100755
--- a/user_guide_src/source/database/query_builder.rst
+++ b/user_guide_src/source/database/query_builder.rst
@@ -908,6 +908,8 @@ Upsert
$builder->upsert()
------------------
+.. versionadded:: 4.3.0
+
Generates an upsert string based on the data you supply, and runs the
query. You can either pass an **array** or an **object** to the
method. By default a constraint will be defined in order. A primary
@@ -929,6 +931,8 @@ The first parameter is an object.
$builder->getCompiledUpsert()
-----------------------------
+.. versionadded:: 4.3.0
+
Compiles the upsert query just like ``$builder->upsert()`` but does not
*run* the query. This method simply returns the SQL query as a string.
@@ -944,10 +948,12 @@ upsertBatch
$builder->upsertBatch()
-----------------------
+.. versionadded:: 4.3.0
+
Generates an upsert string based on the data you supply, and runs the
query. You can either pass an **array** or an **object** to the
method. By default a constraint will be defined in order. A primary
-key will be selected first and then unique keys. Mysql will use any
+key will be selected first and then unique keys. MySQL will use any
constraint by default. Here is an example using an array:
.. literalinclude:: query_builder/108.php
@@ -960,13 +966,16 @@ You can also upsert from a query:
.. literalinclude:: query_builder/115.php
-.. note:: ``setQueryAsData()`` can be used since v4.3.0.
+.. note:: The ``setQueryAsData()``, ``onConstraint()``, and ``updateFields()``
+ methods can be used since v4.3.0.
.. note:: It is required to alias the columns of the select query to match those of the target table.
$builder->onConstraint()
------------------------
+.. versionadded:: 4.3.0
+
Allows manually setting constraint to be used for upsert. This does
not work with MySQL because MySQL checks all constraints by default.
@@ -976,6 +985,9 @@ This method accepts a string or an array of columns.
$builder->updateFields()
------------------------
+
+.. versionadded:: 4.3.0
+
Allows manually setting the fields to be updated when performing upserts.
.. literalinclude:: query_builder/110.php
@@ -1084,14 +1096,24 @@ UpdateBatch
$builder->updateBatch()
-----------------------
+.. note:: Since v4.3.0, the second parameter ``$index`` of ``updateBatch()`` has
+ changed to ``$constraints``. It now accepts types array, string, or ``RawSql``.
+
Generates an update string based on the data you supply, and runs the query.
You can either pass an **array** or an **object** to the method.
Here is an example using an array:
.. literalinclude:: query_builder/092.php
+.. note:: Since v4.3.0, the generated SQL structure has been Improved.
+
The first parameter is an associative array of values, the second parameter is the where key.
+Since v4.3.0, you can also use the ``setQueryAsData()``, ``onConstraint()``, and
+``updateFields()`` methods:
+
+.. literalinclude:: query_builder/120.php
+
.. note:: All values except ``RawSql`` are escaped automatically producing safer queries.
.. warning:: When you use ``RawSql``, you MUST escape the data manually. Failure to do so could result in SQL injections.
@@ -1104,7 +1126,8 @@ You can also update from a query:
.. literalinclude:: query_builder/116.php
-.. note:: ``setQueryAsData()`` can be used since v4.3.0.
+.. note:: The ``setQueryAsData()``, ``onConstraint()``, and ``updateFields()``
+ methods can be used since v4.3.0.
.. note:: It is required to alias the columns of the select query to match those of the target table.
@@ -1146,6 +1169,8 @@ method, or ``emptyTable()``.
$builder->deleteBatch()
-----------------------
+.. versionadded:: 4.3.0
+
Generates a batch **DELETE** statement based on a set of data.
.. literalinclude:: query_builder/118.php
@@ -1198,6 +1223,8 @@ When
$builder->when()
----------------
+.. versionadded:: 4.3.0
+
This allows modifying the query based on a condition without breaking out of the
query builder chain. The first parameter is the condition, and it should evaluate
to a boolean. The second parameter is a callable that will be ran
@@ -1223,6 +1250,8 @@ WhenNot
$builder->whenNot()
-------------------
+.. versionadded:: 4.3.0
+
This works exactly the same way as ``$builder->when()`` except that it will
only run the callable when the condition evaluates to ``false``, instead of ``true`` like ``when()``.
@@ -1415,6 +1444,8 @@ Class Reference
.. php:method:: setQueryAsData($query[, $alias[, $columns = null]])
+ .. versionadded:: 4.3.0
+
:param BaseBuilder|RawSql $query: Instance of the BaseBuilder or RawSql
:param string|null $alias: Alias for query
:param array|string|null $columns: Array or comma delimited string of columns in the query
diff --git a/user_guide_src/source/database/query_builder/092.php b/user_guide_src/source/database/query_builder/092.php
index 911771fc1850..407eadbfef29 100644
--- a/user_guide_src/source/database/query_builder/092.php
+++ b/user_guide_src/source/database/query_builder/092.php
@@ -14,28 +14,7 @@
'date' => 'Date 2',
],
];
-
$builder->updateBatch($data, ['title', 'author']);
-
-// OR
-$builder->setData($data)->onConstraint('title, author')->updateBatch();
-
-// OR
-$builder->setData($data, null, 'u')
- ->onConstraint(['`mytable`.`title`' => '`u`.`title`', 'author' => new RawSql('`u`.`author`')])
- ->updateBatch();
-
-// OR
-foreach ($data as $row) {
- $builder->setData($row);
-}
-$builder->onConstraint('title, author')->updateBatch();
-
-// OR
-$builder->setData($data, true, 'u')
- ->onConstraint(new RawSql('`mytable`.`title` = `u`.`title` AND `mytable`.`author` = `u`.`author`'))
- ->updateFields(['last_update' => new RawSql('CURRENT_TIMESTAMP()')], true)
- ->updateBatch();
/*
* Produces:
* UPDATE `mytable`
@@ -47,6 +26,5 @@
* SET
* `mytable`.`title` = `u`.`title`,
* `mytable`.`name` = `u`.`name`,
- * `mytable`.`date` = `u`.`date`,
- * `mytable`.`last_update` = CURRENT_TIMESTAMP() // this only applies to the last scenario
+ * `mytable`.`date` = `u`.`date`
*/
diff --git a/user_guide_src/source/database/query_builder/115.php b/user_guide_src/source/database/query_builder/115.php
index cc5607d3982c..7526474da334 100644
--- a/user_guide_src/source/database/query_builder/115.php
+++ b/user_guide_src/source/database/query_builder/115.php
@@ -7,7 +7,10 @@
$additionalUpdateField = ['updated_at' => new RawSql('CURRENT_TIMESTAMP')];
-$sql = $builder->setQueryAsData($query)->onConstraint('email')->updateFields($additionalUpdateField, true)->upsertBatch();
+$sql = $builder->setQueryAsData($query)
+ ->onConstraint('email')
+ ->updateFields($additionalUpdateField, true)
+ ->upsertBatch();
/* MySQLi produces:
INSERT INTO `db_user` (`country`, `email`, `name`)
SELECT user2.name, user2.email, user2.country
diff --git a/user_guide_src/source/database/query_builder/120.php b/user_guide_src/source/database/query_builder/120.php
new file mode 100644
index 000000000000..d0bea8a1cfba
--- /dev/null
+++ b/user_guide_src/source/database/query_builder/120.php
@@ -0,0 +1,36 @@
+setData($data)->onConstraint('title, author')->updateBatch();
+
+// OR
+$builder->setData($data, null, 'u')
+ ->onConstraint(['`mytable`.`title`' => '`u`.`title`', 'author' => new RawSql('`u`.`author`')])
+ ->updateBatch();
+
+// OR
+foreach ($data as $row) {
+ $builder->setData($row);
+}
+$builder->onConstraint('title, author')->updateBatch();
+
+// OR
+$builder->setData($data, true, 'u')
+ ->onConstraint(new RawSql('`mytable`.`title` = `u`.`title` AND `mytable`.`author` = `u`.`author`'))
+ ->updateFields(['last_update' => new RawSql('CURRENT_TIMESTAMP()')], true)
+ ->updateBatch();
+/*
+ * Produces:
+ * UPDATE `mytable`
+ * INNER JOIN (
+ * SELECT 'Title 1' `title`, 'Author 1' `author`, 'Name 1' `name`, 'Date 1' `date` UNION ALL
+ * SELECT 'Title 2' `title`, 'Author 2' `author`, 'Name 2' `name`, 'Date 2' `date`
+ * ) `u`
+ * ON `mytable`.`title` = `u`.`title` AND `mytable`.`author` = `u`.`author`
+ * SET
+ * `mytable`.`title` = `u`.`title`,
+ * `mytable`.`name` = `u`.`name`,
+ * `mytable`.`date` = `u`.`date`,
+ * `mytable`.`last_update` = CURRENT_TIMESTAMP() // this only applies to the last scenario
+ */
diff --git a/user_guide_src/source/installation/index.rst b/user_guide_src/source/installation/index.rst
index 3be12a637dcc..8b71ea2833b0 100644
--- a/user_guide_src/source/installation/index.rst
+++ b/user_guide_src/source/installation/index.rst
@@ -10,8 +10,11 @@ Which is right for you?
- If you would like the simple "download & go" install that CodeIgniter3
is known for, choose the manual installation.
-However you choose to install and run CodeIgniter4, the
+However you choose to install and run CodeIgniter4, the latest
`user guide `_ is accessible online.
+If you want to see previous versions, you can download from the
+`codeigniter4/userguide `_
+repository.
.. note:: Before using CodeIgniter 4, make sure that your server meets the
:doc:`requirements `, in particular the PHP
diff --git a/user_guide_src/source/models/model.rst b/user_guide_src/source/models/model.rst
index 726c26dac728..06f7d84f26f2 100644
--- a/user_guide_src/source/models/model.rst
+++ b/user_guide_src/source/models/model.rst
@@ -727,7 +727,7 @@ Additionally, each model may allow (default) or deny callbacks class-wide by set
.. literalinclude:: model/052.php
-You may also change this setting temporarily for a single model call sing the ``allowCallbacks()`` method:
+You may also change this setting temporarily for a single model call using the ``allowCallbacks()`` method:
.. literalinclude:: model/053.php