深入理解 WordPress `WP_Tax_Query` 类的源码:它如何构建复杂的分类法查询条件。

大家好!今天咱们来聊聊 WordPress 里一个相当重要的家伙,WP_Tax_Query。这家伙专门负责构建复杂的分类法查询,让你在 WordPress 世界里按照各种奇葩的方式筛选文章,简直是分类法查询的瑞士军刀。

开场白:分类法的烦恼

想象一下,你经营着一个美食博客。你的文章不仅有“菜系”(比如川菜、粤菜),还有“食材”(比如猪肉、牛肉),甚至还有“烹饪方式”(比如炒、炸、蒸)。现在你想找到所有:

  1. 属于川菜,并且用了猪肉的菜谱。
  2. 属于粤菜,或者用了牛肉的菜谱。
  3. 既不属于川菜,也没用猪肉的菜谱。

这仅仅是开始。如果再加点难度,比如“属于川菜,但不能是麻辣口味的”,你是不是已经开始头疼了?

别慌! WP_Tax_Query 就是来拯救你的。它能帮你把这些复杂的逻辑转化成 WordPress 能够理解的 SQL 查询语句。

WP_Tax_Query 的基本结构

先来看看 WP_Tax_Query 的基本结构。它本质上是一个数组,里面包含了一个或多个分类法查询的条件。每个条件都是一个关联数组,描述了你想对哪个分类法进行怎样的筛选。

$tax_query = new WP_Tax_Query(
    array(
        'relation' => 'AND', // 条件之间的关系,可以是 'AND' 或 'OR'
        array(
            'taxonomy' => 'cuisine', // 分类法名称
            'field'    => 'slug',   // 用于匹配的字段,可以是 'term_id', 'name', 'slug'
            'terms'    => array( 'sichuan' ), // 要匹配的值,可以是单个值或数组
            'operator' => 'IN',   // 匹配操作符,可以是 'IN', 'NOT IN', 'AND', 'EXISTS', 'NOT EXISTS'
        ),
        array(
            'taxonomy' => 'ingredient',
            'field'    => 'slug',
            'terms'    => array( 'pork' ),
            'operator' => 'IN',
        ),
    )
);

这段代码构建了一个 WP_Tax_Query 对象,它表示查询所有属于“川菜”并且使用了“猪肉”的文章。

  • relation: 定义了多个条件之间的关系。 'AND' 表示所有条件都必须满足,'OR' 表示只要满足其中一个条件即可。
  • taxonomy: 指定你要查询的分类法。比如 'category''post_tag' 或者你自己定义的分类法。
  • field: 指定用哪个字段来匹配。常用的有 'term_id' (分类法 ID)、'name' (分类法名称) 和 'slug' (分类法别名)。
  • terms: 指定要匹配的值。可以是一个值,也可以是一个包含多个值的数组。
  • operator: 指定匹配的操作符。这是最灵活的部分,决定了如何将 fieldterms 进行比较。

operator 的各种用法

operatorWP_Tax_Query 的灵魂所在。不同的操作符可以实现各种复杂的筛选逻辑。

操作符 含义
'IN' field 的值必须在 terms 数组中。
'NOT IN' field 的值不能在 terms 数组中。
'AND' field 的值必须同时包含 terms 数组中的所有值(仅当 taxonomy 支持多个 term 时有效,例如多选的自定义分类法)。
'EXISTS' 文章必须关联到指定的 taxonomyterms 会被忽略。
'NOT EXISTS' 文章不能关联到指定的 taxonomyterms 会被忽略。

举几个例子:

  • 'operator' => 'IN', 'terms' => array( 'sichuan', 'cantonese' ): 表示属于川菜 粤菜。
  • 'operator' => 'NOT IN', 'terms' => array( 'spicy' ): 表示不属于“麻辣”口味。
  • 'operator' => 'EXISTS': 表示必须关联到该分类法。
  • 'operator' => 'NOT EXISTS': 表示不能关联到该分类法。

WP_Query 中的应用

WP_Tax_Query 的最终目的是为 WP_Query 提供查询条件。你可以将 WP_Tax_Query 对象放到 WP_Query 的参数中:

$args = array(
    'post_type' => 'recipe',
    'tax_query' => array(
        'relation' => 'AND',
        array(
            'taxonomy' => 'cuisine',
            'field'    => 'slug',
            'terms'    => array( 'sichuan' ),
            'operator' => 'IN',
        ),
        array(
            'taxonomy' => 'ingredient',
            'field'    => 'slug',
            'terms'    => array( 'pork' ),
            'operator' => 'IN',
        ),
    ),
);

$query = new WP_Query( $args );

if ( $query->have_posts() ) {
    while ( $query->have_posts() ) {
        $query->the_post();
        // 显示文章内容
        echo get_the_title() . '<br>';
    }
} else {
    echo '没有找到符合条件的文章。';
}

wp_reset_postdata();

这段代码会查询所有类型为 recipe(假设你自定义了一个文章类型叫 recipe),并且属于“川菜”并且使用了“猪肉”的文章。

源码解析:get_sql() 方法

WP_Tax_Query 最核心的方法是 get_sql()。它负责将你定义的分类法查询条件转化成 SQL 语句。

public function get_sql( $wp_query ) {
    $sql = $this->get_sql_clauses( $wp_query->query_vars['distinct'], $wp_query->posts_request_select );

    return array(
        'where'  => $sql['where'],
        'join'   => $sql['join'],
    );
}

get_sql() 方法调用了 get_sql_clauses() 来生成 SQL 语句的 WHEREJOIN 部分。

get_sql_clauses() 方法是真正的核心。它会遍历你定义的每一个分类法查询条件,并根据 operator 的不同,生成不同的 SQL 子句。

public function get_sql_clauses( $distinct, $select = '' ) {
    global $wpdb;

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

    $tax_query = $this->queries;

    if ( ! empty( $tax_query ) && is_array( $tax_query ) ) {
        $sql_chunks = $this->get_sql_for_query( $tax_query );
        $sql['where'] .= ' ' . $sql_chunks['where'];
        $sql['join']  .= ' ' . $sql_chunks['join'];
    }

    if ( is_array( $this->relation ) && count( $this->relation ) > 0 ) {
        $sql['where'] = preg_replace( '/AND 1=1/', '', $sql['where'], 1 );
        $sql['where'] = '(' . trim( $sql['where'] ) . ')';
    }

    return $sql;
}

这个方法会递归地处理嵌套的查询条件。 如果查询中包含 relation 属性 (AND 或 OR), 那么会处理多个分类法查询之间的关系。 get_sql_for_query 方法是用来生成单个分类法查询的SQL代码的。

让我们深入 get_sql_for_query() 看看它是如何工作的:

protected function get_sql_for_query( $query ) {
    global $wpdb;

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

    $sql_relation = 'AND';
    if ( isset( $query['relation'] ) && in_array( strtoupper( $query['relation'] ), array( 'AND', 'OR' ), true ) ) {
        $sql_relation = $query['relation'];
    }

    $sql_clauses = array();

    foreach ( $query as $key => $clause ) {
        if ( 'relation' === $key ) {
            continue;
        }

        if ( is_array( $clause ) ) {
            $clause_sql = $this->get_sql_for_clause( $clause, $sql_relation );

            if ( ! empty( $clause_sql ) ) {
                $sql_clauses[] = $clause_sql;
            }
        }
    }

    if ( ! empty( $sql_clauses ) ) {
        $sql_chunks['where'] = 'AND ( ' . implode( " {$sql_relation} ", $sql_clauses ) . ' )';
    }

    return $sql_chunks;
}

这个方法遍历查询数组中的每个条件,并调用 get_sql_for_clause() 来获取每个条件的 SQL 代码。然后,它使用 relation 属性(AND 或 OR)将这些 SQL 代码连接起来。

最后,我们来看看 get_sql_for_clause()

protected function get_sql_for_clause( $clause, $op ) {
    global $wpdb;

    $sql = '';

    if ( ! isset( $clause['taxonomy'] ) || ! taxonomy_exists( $clause['taxonomy'] ) ) {
        return $sql;
    }

    $taxonomy = $clause['taxonomy'];
    $tax_obj  = get_taxonomy( $taxonomy );
    $table = _wp_term_relevance_prefixed_table_name( 'term_relationships' );

    $field    = isset( $clause['field'] )    ? $clause['field']    : 'term_id';
    $terms    = isset( $clause['terms'] )    ? $clause['terms']    : array();
    $operator = isset( $clause['operator'] ) ? strtoupper( $clause['operator'] ) : 'IN';

    if ( ! is_array( $terms ) ) {
        $terms = array( $terms );
    }

    if ( empty( $terms ) && ! in_array( $operator, array( 'EXISTS', 'NOT EXISTS' ), true ) ) {
        return $sql;
    }

    $terms = array_map( 'sanitize_term_field', array_fill( 0, count( $terms ), $field ), $terms, array_fill( 0, count( $terms ), $taxonomy ), 'db' );

    $sql_where = '';
    $sql_join  = '';

    switch ( $field ) {
        case 'term_id':
            $term_column = 'term_id';
            break;
        case 'name':
            $term_column = 'name';
            break;
        case 'slug':
            $term_column = 'slug';
            break;
        case 'term_taxonomy_id':
            $term_column = 'term_taxonomy_id';
            break;
        default:
            return $sql;
    }

    $tt_id_column = 'term_taxonomy_id';

    switch ( $operator ) {
        case 'IN':
            $ids    = implode( ',', array_map( 'intval', $terms ) );
            $sql_where = "tt.term_id IN ($ids)";
            break;
        case 'NOT IN':
            $ids    = implode( ',', array_map( 'intval', $terms ) );
            $sql_where = "tt.term_id NOT IN ($ids)";
            break;
        case 'AND':
            $clauses = array();
            foreach ( $terms as $term ) {
                $term = intval( $term );
                $clauses[] = "FIND_IN_SET( {$term}, t.term_id )";
            }
            $sql_where = implode( ' AND ', $clauses );
            break;
        case 'EXISTS':
            $sql_where = "1=1"; // Always true, just ensures the join happens
            break;
        case 'NOT EXISTS':
            $sql_where = "1=1"; // Always true, just ensures the NOT EXISTS clause
            break;
        default:
            return $sql;
    }

    $sql_join = " INNER JOIN {$wpdb->term_taxonomy} AS tt ON {$table}.term_taxonomy_id = tt.term_taxonomy_id INNER JOIN {$wpdb->terms} AS t ON tt.term_id = t.term_id";

    if ( 'NOT EXISTS' === $operator ) {
        $sql = "AND NOT EXISTS ( SELECT 1 FROM {$wpdb->term_taxonomy} AS tt INNER JOIN {$wpdb->terms} AS t ON tt.term_id = t.term_id WHERE tt.taxonomy = '$taxonomy' AND {$table}.term_taxonomy_id = tt.term_taxonomy_id )";
    } else {
        $sql = "AND EXISTS ( SELECT 1 FROM {$wpdb->term_taxonomy} AS tt INNER JOIN {$wpdb->terms} AS t ON tt.term_id = t.term_id WHERE tt.taxonomy = '$taxonomy' AND {$table}.term_taxonomy_id = tt.term_taxonomy_id AND {$sql_where} )";
    }

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

这个方法做了以下事情:

  1. 验证分类法是否存在: 确保你指定的分类法是有效的。
  2. 确定要使用的表和字段: 根据 taxonomyfield 的值,确定要查询的数据库表和字段。
  3. 构建 SQL 子句: 根据 operator 的值,构建不同的 SQL WHERE 子句。 例如,如果 operator'IN',它会生成 tt.term_id IN (1, 2, 3) 这样的 SQL 子句。
  4. 构建 JOIN 子句: 生成必要的 JOIN 子句,将文章表和分类法表连接起来。

一个更复杂的例子

让我们回到美食博客的例子,构建一个更复杂的查询:

$args = array(
    'post_type' => 'recipe',
    'tax_query' => array(
        'relation' => 'AND',
        array(
            'taxonomy' => 'cuisine',
            'field'    => 'slug',
            'terms'    => array( 'sichuan' ),
            'operator' => 'IN',
        ),
        array(
            'relation' => 'OR',
            array(
                'taxonomy' => 'ingredient',
                'field'    => 'slug',
                'terms'    => array( 'pork' ),
                'operator' => 'IN',
            ),
            array(
                'taxonomy' => 'cooking_method',
                'field'    => 'slug',
                'terms'    => array( 'fried' ),
                'operator' => 'IN',
            ),
        ),
    ),
);

这个查询表示:查找所有属于“川菜”,并且使用了“猪肉” 或者 烹饪方式是“炸”的文章。

总结

WP_Tax_Query 是一个强大的工具,可以让你构建复杂的分类法查询。理解它的基本结构和 operator 的用法,可以让你在 WordPress 中灵活地筛选文章。 记住,WP_Tax_Query 的核心是 get_sql() 方法,它负责将你的查询条件转化成 SQL 语句。 深入理解 get_sql() 方法的实现,可以让你更好地掌握 WP_Tax_Query 的工作原理。

希望今天的讲解对你有所帮助! 以后遇到复杂的分类法查询,不要害怕,拿起 WP_Tax_Query 这把瑞士军刀,勇敢地去战斗吧!

发表回复

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