详解 WordPress `meta_query` 源码:`WP_Query` 如何将数组转换为 SQL `JOIN` 和 `WHERE`。

WordPress meta_query 源码解剖:WP_Query 的 SQL 炼金术

嘿,各位代码探险家们,晚上好!今天咱们来聊聊 WordPress 里面一个既强大又有点神秘的功能:meta_query。 很多时候,你想根据自定义字段来筛选文章,比如找到所有“颜色”是“蓝色”的文章,这时候 meta_query 就派上大用场了。 但你知道 WP_Query 是怎么把一个看起来很友好的 PHP 数组,变成一堆复杂的 SQL JOINWHERE 子句的吗? 这就是我们今天要深入研究的“炼金术”!

1. meta_query 的基本结构:PHP 数组的模样

在开始解剖源码之前,我们先回顾一下 meta_query 的基本结构。它本质上是一个多维数组,长得像这样:

$args = array(
    'meta_query' => array(
        'relation' => 'AND', // 可选,AND 或 OR,默认 AND
        array(
            'key'     => 'color',   // 自定义字段的键名
            'value'   => 'blue',  // 要匹配的值
            'compare' => '=',     // 比较运算符,=, !=, >, <, LIKE, IN, BETWEEN, NOT IN, NOT BETWEEN, REGEXP, NOT REGEXP, EXISTS, NOT EXISTS
            'type'    => 'CHAR'    // 数据类型,CHAR, NUMERIC, BINARY, DATE, DATETIME, DECIMAL, SIGNED, UNSIGNED
        ),
        array(
            'key'     => 'price',
            'value'   => array(10, 20),
            'compare' => 'BETWEEN',
            'type'    => 'NUMERIC'
        )
    )
);

$query = new WP_Query( $args );

这个数组定义了多个条件,每个条件针对一个自定义字段。relation 决定了这些条件之间的逻辑关系(AND 或者 OR)。 key 指明了自定义字段的名称,value 是要匹配的值,compare 定义了比较的方式,type 则声明了字段的数据类型。

2. WP_Query 的魔术:从数组到 SQL

WP_Query 接收到 meta_query 数组后,就开始了它的魔术表演。 核心的转换逻辑主要发生在 WP_Query::get_meta_sql() 方法里。 让我们一步步地拆解这个方法,看看它是怎么把数组变成 SQL 的。

2.1. get_meta_sql(): 启动转换引擎

get_meta_sql() 方法接收三个参数:

  • $meta_query:我们提供的 meta_query 数组。
  • $primary_table:主表名,通常是 $wpdb->posts
  • $primary_id_column:主表的主键列名,通常是 'ID'

它的主要任务是:

  1. 标准化 meta_query 数组。
  2. 构建 SQL JOIN 子句。
  3. 构建 SQL WHERE 子句。
/**
 * Get meta query SQL for the WP_Query class.
 *
 * @since 3.1.0
 *
 * @param array  $meta_query Array of meta query clauses.
 * @param string $primary_table Optional. The table to query.
 * @param string $primary_id_column Optional. The primary ID column.
 * @return array Meta query SQL clauses with columns to select.
 */
public function get_meta_sql( $meta_query, $primary_table = '', $primary_id_column = '' ) {
    global $wpdb;

    if ( ! is_array( $meta_query ) ) {
        return array(
            'join'  => '',
            'where' => '',
        );
    }

    $meta_query_instance = new WP_Meta_Query();
    $meta_query_instance->parse_query_vars( $meta_query );

    if ( empty( $meta_query_instance->queries ) ) {
        return array(
            'join'  => '',
            'where' => '',
        );
    }

    $sql = $meta_query_instance->get_sql( $primary_table, $primary_id_column );

    return $sql;
}

注意,WP_Query 并没有直接处理 meta_query,而是创建了一个 WP_Meta_Query 实例来负责。 这体现了“单一职责原则”,让代码更模块化。

2.2. WP_Meta_Query::parse_query_vars():标准化你的数组

WP_Meta_Query::parse_query_vars() 方法负责标准化 meta_query 数组。 也就是说,它会检查数组的结构是否正确,补充缺失的参数,并进行一些必要的清理工作。

例如,如果你的 meta_query 中没有指定 relation,它会自动添加 relation => ‘AND’。它还会把简写的 meta_keymeta_value 转换为完整的 meta_query 数组结构。

/**
 * Parses meta query arguments into clauses.
 *
 * @since 3.2.0
 *
 * @param array $q An array of query arguments.
 */
public function parse_query_vars( $q ) {
    $this->relation = isset( $q['relation'] ) ? strtoupper( $q['relation'] ) : 'AND';
    if ( ! in_array( $this->relation, array( 'AND', 'OR' ), true ) ) {
        $this->relation = 'AND';
    }

    $this->queries = $this->sanitize_query( $q );
    $this->sql_clauses = $this->get_sql_clauses();
}

sanitize_query 会遍历你的 meta_query 数组,确保每个子数组都包含 key, value, compare, type 这些关键字段。 如果缺少某些字段,它会使用默认值进行填充。

2.3. WP_Meta_Query::get_sql():构建 SQL 蓝图

WP_Meta_Query::get_sql() 方法是整个转换过程的核心。 它会遍历标准化后的 meta_query 数组,为每个条件生成对应的 SQL 片段,然后把它们组合起来,形成最终的 JOINWHERE 子句。

/**
 * Generates SQL clauses to be appended to a main query.
 *
 * @since 3.2.0
 *
 * @param string $primary_table The table being queried.
 * @param string $primary_id_column The column joining the primary table to the meta table.
 * @return array An array containing the 'join' and 'where' sub-arrays.
 */
public function get_sql( $primary_table, $primary_id_column ) {
    global $wpdb;

    $sql = array(
        'join'  => array(),
        'where' => array(),
    );

    $sql['where'][] = '(' . implode( " {$this->relation} ", $this->sql_clauses ) . ')';

    if ( ! empty( $this->sql_clauses ) ) {
        $sql['join'] = array_unique( $this->sql_joins );
    }

    $sql['join']  = implode( "n", $sql['join'] );
    $sql['where'] = implode( "n", $sql['where'] );

    return $sql;
}

这个方法调用了get_sql_clauses方法,这个方法实际上是生成SQL语句的核心。

2.4. WP_Meta_Query::get_sql_clauses():SQL 生成器

WP_Meta_Query::get_sql_clauses() 方法是真正生成 SQL 片段的地方。 它会遍历 meta_query 中的每个条件,根据 comparetype 的不同,生成不同的 SQL 子句。

/**
 * Get all of the clauses for the query.
 *
 * @since 4.2.0
 * @return array An array of fully-formed SQL clauses.
 */
protected function get_sql_clauses() {
    global $wpdb;

    $sql = array();

    foreach ( $this->queries as $i => $query ) {
        $sql[] = $this->get_sql_for_clause( $query, $i );
    }

    return $sql;
}

这个方法循环调用了get_sql_for_clause方法,用来逐个生成SQL语句。

2.5. WP_Meta_Query::get_sql_for_clause():精确定制 SQL 子句

WP_Meta_Query::get_sql_for_clause() 方法根据单个 meta_query 条件,生成对应的 SQL 子句。 它是整个过程中最复杂的部分,因为它需要处理各种不同的 comparetype 值。

/**
 * Get the SQL for a single query clause.
 *
 * @since 4.2.0
 *
 * @param array $query A single query clause.
 * @param int   $clause_num The clause's array index.
 * @return string A fully-formed SQL clause.
 */
protected function get_sql_for_clause( $query, $clause_num ) {
    global $wpdb;

    $sql = '';

    $alias = 'mt' . $clause_num;
    $this->sql_joins[ $alias ] = "LEFT JOIN {$wpdb->postmeta} AS {$alias} ON ({$wpdb->posts}.ID = {$alias}.post_id)";

    $field = esc_sql( $query['key'] );
    $compare = strtoupper( $query['compare'] );
    $value = $query['value'];
    $type = strtoupper( $query['type'] );

    switch ( $compare ) {
        case 'IN':
        case 'NOT IN':
            if ( ! is_array( $value ) ) {
                $value = preg_split( '/[,s]+/', $value );
            }

            $value = array_map( 'esc_sql', $value );
            $value = "'" . implode( "', '", $value ) . "'";

            $sql .= "{$alias}.meta_key = '{$field}' AND {$alias}.meta_value {$compare} ({$value})";
            break;

        case 'BETWEEN':
        case 'NOT BETWEEN':
            if ( ! is_array( $value ) || 2 !== count( $value ) ) {
                return '';
            }

            $value[0] = esc_sql( $value[0] );
            $value[1] = esc_sql( $value[1] );

            $sql .= "{$alias}.meta_key = '{$field}' AND {$alias}.meta_value {$compare} '{$value[0]}' AND '{$value[1]}'";
            break;

        case 'LIKE':
        case 'NOT LIKE':
            $value = '%' . esc_sql( like_escape( $value ) ) . '%';
            $sql .= "{$alias}.meta_key = '{$field}' AND {$alias}.meta_value {$compare} '{$value}'";
            break;

        case 'REGEXP':
        case 'NOT REGEXP':
            $value = esc_sql( $value );
            $sql .= "{$alias}.meta_key = '{$field}' AND {$alias}.meta_value {$compare} '{$value}'";
            break;

        case 'EXISTS':
            $sql .= "EXISTS ( SELECT 1 FROM {$wpdb->postmeta} WHERE {$wpdb->posts}.ID = {$wpdb->postmeta}.post_id AND {$wpdb->postmeta}.meta_key = '{$field}' )";
            break;

        case 'NOT EXISTS':
            $sql .= "NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} WHERE {$wpdb->posts}.ID = {$wpdb->postmeta}.post_id AND {$wpdb->postmeta}.meta_key = '{$field}' )";
            break;

        default:
            $value = esc_sql( $value );
            $sql .= "{$alias}.meta_key = '{$field}' AND {$alias}.meta_value {$compare} '{$value}'";
            break;
    }

    return $sql;
}

让我们逐个分析一些常见的 compare 值,看看它们是如何转换成 SQL 的:

  • = (等于)

    $sql .= "{$alias}.meta_key = '{$field}' AND {$alias}.meta_value = '{$value}'";

    这条 SQL 子句会查找 meta_key 等于 $field 并且 meta_value 等于 $value 的记录。

  • LIKE (模糊匹配)

    $value = '%' . esc_sql( like_escape( $value ) ) . '%';
    $sql .= "{$alias}.meta_key = '{$field}' AND {$alias}.meta_value LIKE '{$value}'";

    LIKE 允许你使用通配符(%)进行模糊匹配。 like_escape() 函数会转义 $value 中的特殊字符,防止 SQL 注入。

  • BETWEEN (范围)

    if ( ! is_array( $value ) || 2 !== count( $value ) ) {
        return '';
    }
    
    $value[0] = esc_sql( $value[0] );
    $value[1] = esc_sql( $value[1] );
    
    $sql .= "{$alias}.meta_key = '{$field}' AND {$alias}.meta_value BETWEEN '{$value[0]}' AND '{$value[1]}'";

    BETWEEN 用于查找 meta_value 介于两个值之间的记录。

  • IN (包含于)

    if ( ! is_array( $value ) ) {
        $value = preg_split( '/[,s]+/', $value );
    }
    
    $value = array_map( 'esc_sql', $value );
    $value = "'" . implode( "', '", $value ) . "'";
    
    $sql .= "{$alias}.meta_key = '{$field}' AND {$alias}.meta_value IN ({$value})";

    IN 用于查找 meta_value 包含在指定数组中的记录。

除了 comparetype 也会影响 SQL 的生成。 例如,如果 typeNUMERICWP_Meta_Query 可能会在 meta_value 上使用 CAST() 函数,将其转换为数字类型,以便进行数值比较。

表:compare 运算符与 SQL 子句的对应关系

compare SQL 子句 描述
= {$alias}.meta_key = '{$field}' AND {$alias}.meta_value = '{$value}' 精确匹配 meta_keymeta_value
!= {$alias}.meta_key = '{$field}' AND {$alias}.meta_value != '{$value}' 查找 meta_key 等于 $fieldmeta_value 不等于 $value 的记录。
> {$alias}.meta_key = '{$field}' AND {$alias}.meta_value > '{$value}' 查找 meta_key 等于 $fieldmeta_value 大于 $value 的记录。注意,如果 typeNUMERIC$value 应该是一个数值。
< {$alias}.meta_key = '{$field}' AND {$alias}.meta_value < '{$value}' 查找 meta_key 等于 $fieldmeta_value 小于 $value 的记录。同上,如果 typeNUMERIC$value 应该是一个数值。
LIKE {$alias}.meta_key = '{$field}' AND {$alias}.meta_value LIKE '{$value}' 使用通配符进行模糊匹配。$value 可以包含 %(匹配任意数量的字符)和 _(匹配单个字符)。
NOT LIKE {$alias}.meta_key = '{$field}' AND {$alias}.meta_value NOT LIKE '{$value}' LIKE 相反,查找 meta_value 不匹配 $value 的记录。
IN {$alias}.meta_key = '{$field}' AND {$alias}.meta_value IN ({$value}) 查找 meta_value 包含在 $value 数组中的记录。$value 必须是一个数组。
NOT IN {$alias}.meta_key = '{$field}' AND {$alias}.meta_value NOT IN ({$value}) IN 相反,查找 meta_value 不包含$value 数组中的记录。
BETWEEN {$alias}.meta_key = '{$field}' AND {$alias}.meta_value BETWEEN '{$value[0]}' AND '{$value[1]}' 查找 meta_value 介于 $value[0]$value[1] 之间的记录。$value 必须是一个包含两个元素的数组。
NOT BETWEEN {$alias}.meta_key = '{$field}' AND {$alias}.meta_value NOT BETWEEN '{$value[0]}' AND '{$value[1]}' 查找 meta_value 介于 $value[0]$value[1] 之间的记录。$value 必须是一个包含两个元素的数组。
EXISTS EXISTS ( SELECT 1 FROM {$wpdb->postmeta} WHERE {$wpdb->posts}.ID = {$wpdb->postmeta}.post_id AND {$wpdb->postmeta}.meta_key = '{$field}' ) 查找存在 meta_key 等于 $field 的记录。无需指定 meta_value
NOT EXISTS NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} WHERE {$wpdb->posts}.ID = {$wpdb->postmeta}.post_id AND {$wpdb->postmeta}.meta_key = '{$field}' ) 查找存在 meta_key 等于 $field 的记录。无需指定 meta_value

2.6. 组装 SQL:JOINWHERE 的完美结合

WP_Meta_Query::get_sql() 方法把 get_sql_clauses() 生成的 SQL 片段组合起来,形成最终的 JOINWHERE 子句。

  • JOIN 子句: 用于把 posts 表和 postmeta 表连接起来。 每个 meta_query 条件都会生成一个 LEFT JOIN 子句,使用别名(如 mt0, mt1)来区分不同的连接。

  • WHERE 子句: 包含了所有的 meta_query 条件。 relation (AND 或 OR) 决定了这些条件之间的逻辑关系。

3. 实例分析:从 PHP 到 SQL 的旅程

让我们用一个具体的例子来演示整个转换过程。 假设我们有以下 meta_query

$args = array(
    'meta_query' => array(
        'relation' => 'AND',
        array(
            'key'     => 'color',
            'value'   => 'blue',
            'compare' => '=',
            'type'    => 'CHAR'
        ),
        array(
            'key'     => 'price',
            'value'   => array(10, 20),
            'compare' => 'BETWEEN',
            'type'    => 'NUMERIC'
        )
    )
);

经过 WP_QueryWP_Meta_Query 的处理,最终生成的 SQL JOINWHERE 子句如下:

JOIN:
LEFT JOIN wp_postmeta AS mt0 ON (wp_posts.ID = mt0.post_id)
LEFT JOIN wp_postmeta AS mt1 ON (wp_posts.ID = mt1.post_id)

WHERE:
(
    mt0.meta_key = 'color' AND mt0.meta_value = 'blue'
    AND
    mt1.meta_key = 'price' AND mt1.meta_value BETWEEN '10' AND '20'
)

这个 SQL 语句会查找所有 color 等于 'blue' 并且 price 介于 1020 之间的文章。

4. 总结:meta_query 的力量与责任

meta_query 是 WordPress 中一个非常强大的工具,它允许你根据自定义字段进行灵活的查询。 但正如所有强大的工具一样,meta_query 也需要谨慎使用。

  • 性能: 复杂的 meta_query 可能会导致性能问题,特别是当你的 postmeta 表非常大的时候。 尽量避免使用不必要的 JOINWHERE 子句。

  • SQL 注入: 务必对 meta_keymeta_value 进行适当的转义,防止 SQL 注入攻击。 WordPress 提供了 esc_sql()like_escape() 等函数来帮助你完成这项任务。

  • 数据类型: 确保 type 参数与自定义字段的实际数据类型匹配。 错误的 type 可能会导致查询结果不正确。

掌握了 meta_query 的内部机制,你就可以更好地利用它来构建复杂的 WordPress 查询,并避免一些常见的陷阱。 希望今天的“炼金术”讲座对你有所帮助! 下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注