snicco/better-wpdb

在使用WordPress自定义表时,让您保持安全和理智。

v2.0.0-beta.9 2024-09-07 14:27 UTC

README

codecov Psalm Type-Coverage Psalm level PhpMetrics - Static Analysis PHP-Versions

BetterWPDB 是一个无依赖的小类,它使用 WordPress 创建的默认 mysqli 连接。

目录

  1. 为什么您应该使用这个
    1. wpdb 不使用预处理语句
    2. wpdb 具有糟糕的错误处理
    3. wpdb "慢速"
    4. wpdb 信息量大
    5. wpdb 返回所有内容作为字符串
    6. 静态分析器不喜欢 wpdb
  2. 安装
    1. composer
    2. 设置
  3. 使用方法
    1. 运行预处理查询
    2. 选择
      1. 选择
      2. selectAll
      3. selectRow
      4. selectValue
      5. selectLazy
      6. batchProcess
      7. exists
    3. 插入
      1. 插入
      2. bulkInsert
    4. 更新
      1. 更新
      2. 通过主键更新
    5. 删除
    6. 事务
    7. 日志记录
    8. 查询构建器
  4. 贡献
  5. 问题和PR
  6. 安全性

为什么您应该使用这个

这个库的动机最好通过简单的示例来解释。假设我们在数据库中有以下自定义表。

'CREATE TABLE IF NOT EXISTS `test_table` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `test_string` varchar(10) COLLATE utf8mb4_unicode_520_ci UNIQUE NOT NULL,
  `test_float` FLOAT(9,2) UNSIGNED DEFAULT NULL,
  `test_int` INTEGER UNSIGNED DEFAULT NULL,
  `test_bool` BOOLEAN DEFAULT FALSE,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;', []
);
  • 一个最大长度为10个字符的唯一字符串
  • 一个浮点列
  • 一个无符号整数列
  • 一个布尔列

wpdb 不使用预处理语句

除了 wpdb::prepare() 让您认为 wpdbNOT 使用预处理语句。解释这些差异超出了本README的范围,但作为回顾

当使用预处理语句时,SQL查询和实际值被分别发送到您的数据库。因此,不可能执行任何SQL注入。

wpdb::prepare() 是一个 字符串转义器。这个名称具有误导性,其功能不理想。

您可以在这里了解更多关于这个主题以及为什么使用真正的预处理语句如此重要的信息

❌ // This is not a prepared query

$wpdb->get_results(
    $wpdb->prepare('select * from `test_table` where `test_int` = %d and `test_string` = %s', [1, 'foo'])
);


✅ // This is a "real" prepared query

$better_wpdb->preparedQuery('select * from `wp_users` where `id` = ?' and `test_string` = ?, [1, 'foo']);

wpdb 具有糟糕的错误处理

wpdb 类中的错误处理几乎不存在。并且当 wpdb 失败时,它会优雅地失败。但是,无法从数据库错误中恢复,因为您的应用程序处于未知状态,因此您希望您的数据库层 大声且强烈地失败。

  1. 让我们比较完全错误的SQL的错误处理。

    wpdb 将返回 (bool) false 表示失败查询,这导致您对结果进行类型检查或对每个单独的SQL查询进行类型检查,然后(希望)抛出异常。

❌ // This is what you typically see in WordPress code

$result = $wpdb->query('apparently not so valid sql');

if($result === false) {
    throw new Exception($wpdb->last_error);
}
✅ // This is how it should be

$result = $better_wpdb->preparedQuery('apparently not so valid sql');

// You will never ever get here.

var_dump($e->getMessage()) // You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'apparently not a valid SQL statement' at line 1
                           // Query: [apparently not a valid SQL statement]
                           // Bindings: []
  1. 插入比定义的列大太多的数据。

    记住,在我们的数据库表定义中,我们确实将 test_string 列设置为 varchar(10)。

$result = $wpdb->insert('test_table', [
    // the limit is 10, we insert 11 chars
    'test_string' => str_repeat('X', 11)
])

var_dump($result) // (bool) false
var_dump($wpdb->last_error); // WordPress database error: Processing the value for the following field failed: test_string.
                             // The supplied value may be too long or contains invalid data.

// Notice that there is no mention of the invalid query or what type of data was inserted
// As a little benefit you also have nothing in your error log.
$result = $better_wpdb->insert('test_table', [
     'test_string' => str_repeat('X', 11)
])

// You will never ever get here.

var_dump($e->getMessage()) // Data too long for column 'test_string' at row 1
                           // Query: [insert into test_table (test_string) values(?)]
                           // Bindings: ['XXXXXXXXXXX']

// This exception message is automatically logged.
  1. 插入完全错误的数据

    我们定义 test_int 为无符号整数。让我们看看如果我们尝试插入一个负数会发生什么。

$result = $wpdb->insert('test_table', [
    'test_string' => 'bar'
    'test_int' => -10
])

var_dump($result) // (bool) true
var_dump($wpdb->last_error); // ''

// Congratulations. Your database now contains invalid data and you will never know about it.
$result = $better_wpdb->insert('test_table', [
     'test_string' => 'bar'
     'test_int' => -10
])

// You will never ever get here.

var_dump($e->getMessage()) // Out of range value for column 'test_int' at row 1
                           // Query: [insert into test_table (test_string, test_int) values (?,?)]
                           // Bindings: ['bar', -10]

// This exception message is automatically logged.
  1. wpdb 只能以HTML格式打印错误,并且只能记录到配置的 error_log 目的地

    如果 wpdb 竟然捕获了一个完全错误的数据库错误(并且你已开启 show_errors),那么 wpdb 就会直接 echo 输出为 HTML(这在单元测试和 REST API 调用期间非常有用)。错误记录是 硬编码的,祝你好运将数据库错误发送到 SentryNew Relic 或使用任何 PSR 记录器。

wpdb 是“慢”的

这与优雅的错误处理直接相关。

❌ 在 wpdb 对每个查询进行检查之前,都会检查查询是否与表/列字符集和校对兼容。 wpdb 还会通过获取整个表信息来验证写入操作的数据与提供的数据。如果查询被认为不兼容(返回 (bool) false),你将永远不会知道。

✅ 只需为连接设置一次字符集和校对,让 MySQL 处理它已经可以处理的事情。

wpdb 详尽无遗,容易误用,难以调试。

wpdb 的 API 无需如此详尽。此外,它难以正确使用,并且 容易出错

WordPress 插件中看起来像这样的代码数量令人震惊。

$where = "WHERE foo = '" . esc_sql($_GET['data']) . "'";
$query = $wpdb->prepare("SELECT * FROM something $where LIMIT %d, %d", 1, 2);
$result = $wpdb->get_results($query);

✅ 

$result = $better_wpdb->selectAll('select * from something where foo = ? LIMIT ?', [1, 2]);

如果你不知道为什么这不好,请在这里停止并 阅读 PHP 核心贡献者 Anthony Ferrara 的这篇文章

“当前系统是设计不安全的。这并不意味着它总是可被黑客攻击,但这意味着你必须积极工作才能使其不可攻击。切换到默认安全的设计,将不安全情况作为例外情况会更好。”

wpdb 将所有内容都返回为字符串

$wpdb->insert('test_table', [
    'test_string' => 'foo',
    'test_int' => 10,
    'test_float' => 20.50,
    'test_bool' => true
])

❌  

$row = $wpdb->get_row($wpdb->prepare('select * from test_table where test_string = %s', 'foo'));

var_dump($row['test_string']); // (string) foo
var_dump($row['test_int']); // (string) 1
var_dump($row['test_float']); // (string) 20.50
var_dump($row['test_bool']); // (string) 1$row = $better_wpdb->selectRow('select * from test_table where test_string = ?', 'foo');

var_dump($row['test_string']); // (string) foo
var_dump($row['test_int']); // (int) 1
var_dump($row['test_float']); // (float) 20.50
var_dump($row['test_bool']); // (int) 1

静态分析器如 Psalm 和 PHPStan 在理解 wpdb 时会遇到困难。

这与错误处理相关,根据失败或成功返回不同的值。让我们比较 wpdb 和 better_wpdb 的返回签名

❌ // The abbreviated phpdoc of wpdb::get_row
   // This method has 4 different return types? Also, what is return void?

/**
*
* @param string|null $query 
* @param string $output 
* @param int $y 
* @return array|object|null|void Database query result in format specified by $output or null on failure.
*/
public function get_row($query = null, $output = OBJECT, $y = 0) {
   //
 }

✅  // Your favorite static analysis tool will thank you.

 /**
 * @param non-empty-string $sql
 * @param array<scalar|null> $bindings
 *
 * @return array<string, string|int|float|null>
 *
 * @throws NoMatchingRowFound
 * @throws QueryException
 * @throws InvalidArgumentException
 */
 public function selectRow(string $sql, array $bindings): array {
    //
 }

安装

您可以通过 composer 安装 BetterWPDB。唯一的要求是 php: ^7.4|^8.0。没有其他依赖项。

composer

composer require snicco/betterwpdb

设置

BetterWPDB 不会 为您的数据库打开第二个连接。您要开始使用它只需执行以下操作

// require composer autoloader

use Snicco\Component\BetterWPDB\BetterWPDB;

$better_wpdb = BetterWPDB::fromWpdb();

可选地,您也可以传递一个已连接的 mysqli 实例(如果您正在连接到辅助数据库等)

// require composer autoloader

use Snicco\Component\BetterWPDB\BetterWPDB;

$mysqli = /* ... */

$better_wpdb = new BetterWPDB($mysqli);

使用方法

运行预处理查询

如果您需要完全控制 SQL 查询或具有复杂的使用场景,您可以直接使用低级别的 preparedQuery 方法。此方法将返回一个 mysqli_stmt 实例。对于大多数使用场景,还有更多高级方法可用。

!重要:如果您使用的是 preparedQuery 且您的查询是 SELECT 查询,您需要手动恢复默认的错误处理。

所有其他方法都会自动处理此问题。

use Snicco\Component\BetterWPDB\BetterWPDB;

$mysqli = /* ... */

$better_wpdb = new BetterWPDB($mysqli);

// Only for select queries.
$auto_restore_error_handling = false;

// stmt is an instance of mysqli_stmt
$stmt = $better_wpdb->preparedQuery(
    'select * from test_table where test_string = ? or test_int = ?', 
    ['foo', 1],
    $auto_restore_error_handling
);

var_dump($stmt->num_rows);
var_dump($stmt->affected_rows);

$better_wpdb->restoreErrorHandling();

❌ 永远不要将任何用户输入传递到 preparedQuery 的第一个参数中

✅ 使用 "?" 占位符为用户输入传递数组值。

❌ 不要允许用户提供表名、列名、排序值或类似的内容

❌❌❌ // NEVER EVER DO THIS. You will get hacked.

$order_by = $_GET['order'];

$better_wpdb->preparedQuery(
   'select * from test_table where test_string = ? order by ?', 
   [$_GET['test_string'], $order_by]
)

✅ // Use a whitelist approach

$order_by = 'desc';
$_get_order_by = strtolower($_GET['order_by']);

if('asc' === $_get_order_by) {
    $order_by = 'asc';
}

$better_wpdb->preparedQuery(
   'select * from test_table where test_string = ? order by ?', 
   [$_GET['test_string'], $order_by]
)

如果遵循这三条简单的规则,您将 100% 防止 SQL 注入。

选择

选择

这是最低级别的选择方法。返回 mysqli_result 实例。

/** @var mysqli_result $result */
$result = $better_wpdb->select('select * from test_table where test_string = ?', ['foo']);

echo $result->num_rows

while($row = $result->fetch_array()) {
    // Do stuff with $row
}

selectAll

返回所有匹配的记录的数组。

如果您需要查询大量行,使用 selectLazy 更为合适。

/** @var array<array> $result */
$rows = $better_wpdb->selectAll('select * from test_table where test_string = ?', ['foo']);

foreach ($rows as $row) {
   echo $row['test_string'];
   echo $row['test_int'];
   echo $row['test_bool'];
   echo $row['test_float'];
}

selectLazy

有时您可能需要从数据库中查询大量记录以进行处理。一个典型的用例是将10万个订单导出到CSV文件。如果您尝试使用selectAll来执行此操作,您将立即耗尽内存。

这正是selectLazy方法极为有用之处。它返回一个PHP生成器,它始终只在内存中保留1行。

❌ // you just loaded 100k rows into memory

$orders = $better_wpdb->selectAll('select * from orders where created_at <= ?', [$date]);

✅ // You load 1 row at a time. But only when you start looping over the result.

/** @var Generator<array> $orders */
$orders = $better_wpdb->selectLazy('select * from orders where created_at <= ?', [$date]);

// You have not made any db queries yet.

foreach ($orders as $order) {
    // One order is fetched at a time.
    // You only make one db query. But thanks to the generator you only have one order in memory
    
    // process order
}

batchProcess

如果您需要根据只能由PHP代码执行的逻辑选择大量需要更新的记录,可以使用batchProcess方法。

此方法将执行高性能的键集分页。

查询的排序列必须确保确定的排序顺序。此外,不应在批量处理回调中更新排序列的值。

有关底层键集分页的更多信息,请参阅此处此处强烈推荐阅读,以有效地使用此方法

以下代码演示了如何重置所有用户的密码。有关更多示例,请查看此处测试用例

use Snicco\Component\BetterWPDB\BetterWPDB;
use Snicco\Component\BetterWPDB\KeysetPagination\Lock;
use Snicco\Component\BetterWPDB\KeysetPagination\Query;

// This should be a simple SQL query. It can contain where clauses with placeholder ("?"). 
// But make sure that the (static) conditions have the proper indexes, otherwise this method will not perform well.
// DON'T add any LIMIT or ORDER BY clauses.
$sql = 'select ID from wp_users';

// With a (static) condition:
// $sql = 'select ID form wp_posts where post_type = ?'

// The combination of the sorting columns must be a unique record. 
// This is typically done by using the tables primary key. However, compound primary keys are supported.
// (http://mysql.rjweb.org/doc.php/deletebig#iterating_through_a_compound_key)
$deterministic_sorting_columns = ['ID' => 'asc'];

// The batch size is the number of records that are passed to the callback function at once.
$batch_size = 500;

// Optional: The values for static conditions (if any are used).
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
//
// THIS IS THE ONLY PLACE WHERE USER INPUT IS ALLOWED.
$static_column_bindings = [];
//$static_column_bindings = ['some-post-type']
//
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

$query = new Query($sql, ['ID' => 'asc'], $deterministic_sorting_columns, $batch_size, $static_column_bindings);

// (Optional) database lock. If a lock is passed as a third argument to the batchProcess method
// each batch of records will be executed in a database transaction using the provided lock type.
$lock = Lock::forReadWrite();

// Your user-defined callback function.
// $records is an array containing $batch_size database records.
// You can return arbitrary values from this method, they will be returned as an array. 
$callback = function(array $records) :array {
      
    $failed = [];
        
    foreach ($records as $record) {
        
      $id = $record['ID'];
            
      try {
        
        $user_login = get_user_by('id', $ID)->user_login;
        
        retrieve_password($user_login);
        
      } catch (Throwable $e) {
         $failed[] = $id;
      } 
    
    }
    
    return $failed;
}

/** @var BetterWPDB $db */
$db = /* */

/** @var array<int[]> $failed_records */
$failed_records = $db->batchProcess($query, $callback, $lock); 

selectRow

返回与提供的查询匹配的第一行。如果没有找到行,则抛出NoMatchingRowFound实例。

try {

    /** @var array $row */
    $row = $better_wpdb->selectRow('select * from test_table where test_string = ? limit 1', ['foo']);
    
    echo $row['test_string'];
    echo $row['test_int'];
    echo $row['test_bool'];
    echo $row['test_float'];
    
}catch (NoMatchingRowFound $e) {
    // don't catch this exception. Just a demo.
}

selectValue

从单行中选择单个值或在没有找到行时抛出异常。

try {

    /** @var int $row */
    $count = $better_wpdb->selectValue('select count(*) from test_table where test_string = ?', ['foo']);
    
}catch (NoMatchingRowFound $e) {
    // don't catch this exception. Just a demo.
}

exists

您可以使用此方法来检查记录是否存在于数据库中

/** @var bool $exists */
$exists = $better_wpdb->exists('test_table', [
   'test_string' => 'foo', 
   'test_float' => null, 
   'test_int' => 1
   ])

❌永远不要允许用户输入作为数组键。

插入

插入

将单行插入数据库并返回mysqli_stmt实例。

/** @var mysqli_stmt $stmt */
$stmt = $better_wpdb->insert('test_table', [
    'test_string' => 'foo',
    'test_int' => 10
]);

var_dump($stmt->affected_rows);  // (int) 1, always
var_dump($stmt->insert_id);  // (int) 10, assuming we had 9 previous records and auto-incrementing ids.

❌永远不要允许用户输入作为数组键。

bulkInsert

一个常见的用例是同时插入多条记录并确保所有记录都插入或一条都不插入。

例如,将成员的CSV导入数据库。您不希望5次插入失败而5次成功。此方法可以帮助您实现这一点。所有插入都将在一个数据库事务中执行,该事务在成功时自动提交,在发生任何错误时回滚。

$result = $better_wpdb->bulkInsert('test_table', [
  ['test_string' => 'foo', 'test_float' => 10.00, 'test_int' => 1],
  ['test_string' => 'bar', 'test_float' => 20.00, 'test_int' => 2, ],
]);

var_dump($result); // (integer) 2

// This will fail since test_int can not be negative. No rows will be inserted

$result = $better_wpdb->bulkInsert('test_table', [
  ['test_string' => 'foo1', 'test_int' => 1],
  
  /* .. */ 
  
  ['test_string' => 'foo999', 'test_int' => 999],
  
  // This will throw an exception and everything will automatically roll back.
  ['test_string' => 'foo1000', 'test_int' => -1000],
]);

❌永远不要允许用户输入作为数组键。

您可以将任何可迭代对象传递给bulkInsert

这就是在不耗尽内存的情况下将大型CSV文件导入数据库的方法。

// please don't copy-paste this code. It's just an example.

$read_csv = function() :Generator{

   $file = fopen('/path/to/hugh/csv/orders.csv')
   
   while(!feof($file)) {
  
    $row = fgetcsv($file, 4096);
    yield $row
   }
}

$importer_rows_count = $better_wpdb->bulkInsert('orders', $read_csv());

var_dump($importer_rows_count); // 100000

更新

updateByPrimary

根据主键更新记录。默认情况下,将假设主键列名为id

 /** @var int $affected_rows */
 $affected_rows = $better_wpdb->updateByPrimary('test_table', 1, [
            'test_string' => 'bar',
            'test_int' => 20,
 ]);

 // Use a custom column name
 $affected_rows = $better_wpdb->updateByPrimary('test_table', ['my_id' => 1] , [
            'test_string' => 'bar',
            'test_int' => 20,
 ]);

❌永远不要允许用户输入作为数组键。

更新

一个通用更新方法。第二个参数是条件数组,第三个参数是更改数组。

 /** @var int $affected_rows */
 $affected_rows = $better_wpdb->update('test_table',
            ['test_int' => 10], // conditions
            ['test_bool' => true] // changes
        );

❌永远不要允许用户输入作为条件键。

❌永远不要允许用户输入作为更改键。

删除

delete

删除与提供的条件匹配的所有记录。

 /** @var int $deleted_rows */
 $deleted_rows = $better_wpdb->delete('test_table', ['test_string' => 'foo']);

❌永远不要允许用户输入作为条件键。

事务

不幸的是,在WordPress插件中很少使用数据库事务。事务确保事务内的所有或所有db查询都成功,或者都失败。

在许多WordPress插件中常见的典型代码

❌ // This is awful. What happens if a customer and an order is created but creating the payment fails?

 my_plugin_create_customer();
 my_plugin_create_create();
 my_plugin_create_payment();

✅ // wrap these calls inside a database transaction

$better_wpdb->transactional(function () {

    my_plugin_create_customer();
    my_plugin_create_create(); 
    my_plugin_create_payment(); // If this fails, customer and order will not be created.
 
});

日志记录

您可以为BetterWPDB的构造函数传递第二个参数。

实现简单的QueryLogger接口,并开始将数据库查询记录到您最喜欢的分析服务。

以下是将日志记录到New Relic的伪代码

<?php

use Snicco\Component\BetterWPDB\BetterWPDB;use Snicco\Component\BetterWPDB\QueryInfo;use Snicco\Component\BetterWPDB\QueryLogger;

class NewRelicLogger implements QueryLogger {
    
     public function log(QueryInfo $info) :void {
         
         $sql = $info->sql;
         $duration = $info->duration_in_ms;
         $start_time = $info->start;
         $end_time = $info->end
         
         // log to new relic
         
     }   
}

$better_wpdb = BetterWPDB::fromWpdb(new NewRelicLogger());

// Now, all queries, including the sql and duration are logged automatically
$better_wpdb->insert('test_table' , ['test_string' => 'foo']);

查询构建器

BetterWPDB不是一个查询构建器,除非您的查询是动态的,否则不需要。

大多数情况下,纯SQL查询更易于阅读和调试。

❌ // You don't need a query builder. The query is always the same. Only the input changes.

$query = SomeQueryBuilder::table('table')
        ->select([['col1', 'col1']])
        ->where('condition_1' = ?)
        ->andWhere('condition2' = ?)
        ->orWhere('condition3' = ?)
        ->limit(1)
        ->orderBy('desc')

✅ // As plain sql.

$query = 'select col1, col1
          from table
          where ( condition_1 = ? and condition2 = ? )
          or condition3 = ?
          limit 1
          order by desc'

$result = $better_wpdb->selectAll($query, ['foo', 'bar', 'baz']);

如果您的某些查询非常动态,您可以考虑使用latitude,这是一个完整的查询构建器,它与BetterWPDB配合得很好。

composer require latitude/latitude

use Latitude\QueryBuilder\Engine\CommonEngine;
use Latitude\QueryBuilder\QueryFactory;

use function Latitude\QueryBuilder\field;

$factory = new QueryFactory(new CommonEngine());
$query = $factory
    ->select('id', 'username')
    ->from('users')
    ->where(field('id')->eq(5))
    ->compile();

$sql = $query->sql(); // SELECT "id" FROM "users" WHERE "id" = ?
$bindings = $query->params(); // [5]

$results = $better_wpdb->selectRow($sql, $bindings);

贡献

此存储库是 Snicco 项目 开发仓库的只读分支。

以下是如何贡献的说明.

报告问题和发送拉取请求

请在 Snicco 单一仓库 中报告问题。

安全性

如果您在 BetterWPDB 中发现安全漏洞,请遵循我们的 披露程序