剖析 WordPress `WP_Tax_Query` 类的源码:它是如何作为 `WP_Query` 的一个子类,专门处理分类法查询的。

各位观众老爷,大家好!今天咱们来聊聊 WordPress 里一个相当重要,但又经常被人忽视的小伙伴—— WP_Tax_Query。 别看它名字里带着“Tax”,可它不是税务局的,而是专门负责处理分类法查询的。 咱们要把它扒个底朝天,看看它到底是怎么工作的,又是怎么跟 WP_Query 勾搭上的。

一、WP_Query 的分类法查询痛点

先说说 WP_QueryWP_Query 是 WordPress 里查询文章的核心类,几乎所有文章列表的展示,都离不开它。 它很强大,可以根据各种条件查询文章,比如关键词、作者、日期等等。 但是,如果要根据分类法(比如分类、标签)来查询文章,事情就变得稍微复杂了。

WP_Query 本身提供了 category_nametag 等参数,可以简单地根据分类名或标签名来查询。 但是,如果需要更复杂的分类法查询,比如:

  • 查询同时属于 A 分类和 B 标签的文章。
  • 查询属于 A 分类,但不属于 B 标签的文章。
  • 查询属于多个分类中的任意一个的文章。

这些情况,WP_Query 自带的参数就有点力不从心了。 这时候,就需要 tax_query 这个参数来帮忙了。 tax_query 接收的就是一个 WP_Tax_Query 对象(或者一个数组,WordPress 会自动帮你转换成 WP_Tax_Query 对象)。

二、WP_Tax_Query 闪亮登场

WP_Tax_Query 类的主要作用就是构建复杂的分类法查询条件。 它可以将各种分类法查询条件组合起来,形成一个完整的 SQL 查询语句片段,然后嵌入到 WP_Query 的主查询中。

来看一下 WP_Tax_Query 的基本结构:

<?php
/**
 * Core class used for querying taxonomy relationships.
 *
 * @since 3.1.0
 *
 * @see WP_Query::parse_query()
 */
class WP_Tax_Query {

    /**
     * Array of taxonomy queries.
     *
     * @since 3.1.0
     * @var array
     */
    public $queries = array();

    /**
     * Relation between the taxonomy queries.
     *
     * Possible values are 'AND', 'OR'.
     *
     * @since 3.1.0
     * @var string
     */
    public $relation = 'AND';

    /**
     * Taxonomy table.
     *
     * @since 3.1.0
     * @access public
     * @var string
     */
    public $taxonomy_table;

    /**
     * Terms table.
     *
     * @since 3.1.0
     * @access public
     * @var string
     */
    public $terms_table;

    /**
     * Term taxonomy table.
     *
     * @since 3.1.0
     * @access public
     * @var string
     */
    public $term_taxonomy_table;

    /**
     * Hashes the value of the query, which makes it possible to identify
     * duplicate queries.
     *
     * @since 3.7.0
     * @access public
     * @var string
     */
    public $query_vars_hash;

    /**
     * Hashes the SQL that is produced by the query. Used for testing.
     *
     * @since 3.7.0
     * @access public
     * @var string
     */
    public $sql_clauses_hash;

    /**
     * List of tables used in the query.
     *
     * @since 3.1.0
     * @access public
     * @var array
     */
    public $table_aliases = array();

    /**
     * Constructor.
     *
     * @since 3.1.0
     *
     * @param array $tax_query {
     *     Array of taxonomy query clauses.
     *
     *     @type string $relation Optional. The logical operation to perform. 'AND' or 'OR'.
     *                                   Default 'AND'.
     *     @type array  $0        Taxonomy query clause.
     *     @type array  ...        More taxonomy query clauses.
     * }
     */
    public function __construct( $tax_query = array() ) {
        if ( isset( $tax_query['relation'] ) && in_array( strtoupper( $tax_query['relation'] ), array( 'AND', 'OR' ), true ) ) {
            $this->relation = strtoupper( $tax_query['relation'] );
        }

        if ( isset( $tax_query[0] ) && is_array( $tax_query[0] ) ) {
            $this->queries = $tax_query;
        } else {
            $this->queries = array( $tax_query );
        }
    }

    /**
     * Parses a single taxonomy query array.
     *
     * @since 3.1.0
     * @access public
     *
     * @param array $query Taxonomy query clause.
     * @return array Fully parsed taxonomy query clause, or false on error.
     */
    public function sanitize_query( $query ) {
        $query = wp_parse_args( $query, array(
            'taxonomy'         => '',
            'terms'            => array(),
            'field'            => 'term_id',
            'operator'         => 'IN',
            'include_children' => true,
        ) );

        if ( empty( $query['taxonomy'] ) ) {
            return false;
        }

        $taxonomy = get_taxonomy( $query['taxonomy'] );
        if ( ! $taxonomy ) {
            return false;
        }

        $query['taxonomy'] = $taxonomy->name;
        $query['field']    = sanitize_key( $query['field'] );
        $query['operator'] = strtoupper( $query['operator'] );

        if ( ! is_array( $query['terms'] ) ) {
            $query['terms'] = array( $query['terms'] );
        }

        if ( 'slug' === $query['field'] ) {
            $query['terms'] = array_map( 'sanitize_title', $query['terms'] );
        } elseif ( 'name' === $query['field'] ) {
            $query['terms'] = array_map( 'sanitize_text_field', $query['terms'] );
        } else {
            $query['terms'] = array_map( 'intval', $query['terms'] );
        }

        if ( ! in_array( $query['operator'], array( 'IN', 'NOT IN', 'AND', 'EXISTS', 'NOT EXISTS', 'BETWEEN', 'NOT BETWEEN' ), true ) ) {
            $query['operator'] = 'IN';
        }

        return $query;
    }

    /**
     * Constructs the taxonomy query SQL.
     *
     * @since 3.1.0
     * @access public
     *
     * @global wpdb $wpdb WordPress database abstraction object.
     *
     * @param string $primary_table Database table to query.
     * @param string $primary_id_column ID column for the primary table.
     * @return array SQL query clauses.
     */
    public function get_sql( $primary_table, $primary_id_column ) {
        global $wpdb;

        $sql_chunks = $this->get_sql_clauses( $primary_table, $primary_id_column );

        /**
         * Filters the taxonomy query SQL clauses.
         *
         * @since 3.2.0
         *
         * @param array       $sql_chunks  Array containing the JOIN and WHERE SQL clauses.
         * @param string      $primary_table Database table to query.
         * @param string      $primary_id_column ID column for the primary table.
         * @param WP_Tax_Query $this The WP_Tax_Query instance.
         */
        return apply_filters( 'get_tax_sql', $sql_chunks, $primary_table, $primary_id_column, $this );
    }

    /**
     * Generates SQL clauses to be appended to a main query.
     *
     * @since 3.1.0
     * @access public
     *
     * @global wpdb $wpdb WordPress database abstraction object.
     *
     * @param string $primary_table Database table to query.
     * @param string $primary_id_column ID column for the primary table.
     * @return array {
     *     Array containing the JOIN and WHERE SQL clauses.
     *
     *     @type string $join  SQL fragment for the JOIN clause.
     *     @type string $where SQL fragment for the WHERE clause.
     * }
     */
    public function get_sql_clauses( $primary_table, $primary_id_column ) {
        global $wpdb;

        $this->taxonomy_table      = _wp_get_taxonomy_table();
        $this->terms_table         = $wpdb->terms;
        $this->term_taxonomy_table = $wpdb->term_taxonomy;

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

        $sql['where'][] = '1=1';

        if ( empty( $this->queries ) ) {
            return $sql;
        }

        $num_queries = count( $this->queries );

        // If there is only one query, use the un-prefixed alias.
        if ( 1 === $num_queries ) {
            $sql_query = $this->get_sql_for_query( $this->queries[0], $primary_table, $primary_id_column );

            if ( ! empty( $sql_query['where'] ) ) {
                $sql['join'][]  = $sql_query['join'];
                $sql['where'][] = $sql_query['where'];
            }

            return $sql;
        }

        $sql['where'][] = '(';

        $rels = array( 'AND', 'OR' );
        $sql_chunks = array();
        for ( $i = 0; $i < $num_queries; $i++ ) {
            $sql_query = $this->get_sql_for_query( $this->queries[ $i ], $primary_table, $primary_id_column );

            if ( ! empty( $sql_query['where'] ) ) {
                $sql['join'][]  = $sql_query['join'];
                $sql_chunks[] = $sql_query['where'];
            }

            if ( $i < $num_queries - 1 ) {
                if ( in_array( $this->relation, $rels, true ) ) {
                    $sql_chunks[] = $this->relation;
                } else {
                    $sql_chunks[] = 'AND';
                }
            }
        }

        $sql['where'][] = implode( ' ', $sql_chunks );
        $sql['where'][] = ')';

        return $sql;
    }

    /**
     * Generates SQL for a single taxonomy query.
     *
     * @since 3.1.0
     * @access protected
     *
     * @global wpdb $wpdb WordPress database abstraction object.
     *
     * @param array  $query             A taxonomy query clause.
     * @param string $primary_table     Database table to query.
     * @param string $primary_id_column ID column for the primary table.
     * @return array {
     *     Array containing the JOIN and WHERE SQL clauses.
     *
     *     @type string $join  SQL fragment for the JOIN clause.
     *     @type string $where SQL fragment for the WHERE clause.
     * }
     */
    protected function get_sql_for_query( $query, $primary_table, $primary_id_column ) {
        global $wpdb;

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

        $query = $this->sanitize_query( $query );

        if ( ! $query ) {
            return $sql;
        }

        $taxonomy = get_taxonomy( $query['taxonomy'] );

        if ( ! $taxonomy || is_wp_error( $taxonomy ) ) {
            return $sql;
        }

        $terms = $query['terms'];
        $field = $query['field'];
        $operator = $query['operator'];

        if ( empty( $terms ) ) {
            return $sql;
        }

        $terms = array_map( 'strval', $terms );
        $this->has_terms = true;

        $table_alias = 'tt';
        $i = 1;
        while ( isset( $this->table_aliases[ $table_alias ] ) ) {
            $i++;
            $table_alias = 'tt' . $i;
        }
        $this->table_aliases[ $table_alias ] = true;

        $sql['join'] = " INNER JOIN {$this->term_taxonomy_table} AS {$table_alias} ON ( {$primary_table}.{$primary_id_column} = {$table_alias}.object_id )";

        if ( 'term_id' === $field ) {
            $term_ids = array_map( 'intval', $terms );
            $where = wp_sprintf( '%s.term_id %s (%l)', $table_alias, $operator, $term_ids );
        } elseif ( 'term_taxonomy_id' === $field ) {
            $term_taxonomy_ids = array_map( 'intval', $terms );
            $where = wp_sprintf( '%s.term_taxonomy_id %s (%l)', $table_alias, $operator, $term_taxonomy_ids );
        } elseif ( 'slug' === $field ) {
            $slugs = array_map( array( $wpdb, 'esc_like' ), $terms );
            $where = wp_sprintf( '%s.slug %s (%s)', $this->terms_table, $operator, implode( ',', array_map( array( $wpdb, 'prepare' ), array_fill( 0, count( $slugs ), '%s' ), $slugs ) ) );
        } elseif ( 'name' === $field ) {
            $names = array_map( array( $wpdb, 'esc_like' ), $terms );
            $where = wp_sprintf( '%s.name %s (%s)', $this->terms_table, $operator, implode( ',', array_map( array( $wpdb, 'prepare' ), array_fill( 0, count( $names ), '%s' ), $names ) ) );
        } else {
            return $sql;
        }

        $sql['join'] = apply_filters( 'get_tax_sql_join', $sql['join'], $query, $table_alias, $primary_table, $primary_id_column );
        $sql['where'] = apply_filters( 'get_tax_sql_where', $where, $query, $table_alias, $primary_table, $primary_id_column );

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

    /**
     * Checks whether the current query has terms.
     *
     * @since 4.7.0
     * @access public
     *
     * @return bool True if query has terms, false otherwise.
     */
    public function has_terms() {
        return ! empty( $this->has_terms );
    }

    /**
     * Sets the value of query vars, which make it possible to identify
     * duplicate queries.
     *
     * @since 3.7.0
     * @access public
     *
     * @param array $query_vars The array of query variables.
     */
    public function set_query_vars( $query_vars ) {
        $this->query_vars_hash = md5( serialize( $query_vars ) );
    }

    /**
     * Sets the value of the query's SQL clauses, which makes it possible to
     * identify duplicate queries.
     *
     * @since 3.7.0
     * @access public
     *
     * @param string $sql_clauses The query's complete SQL clauses.
     */
    public function set_sql_clauses( $sql_clauses ) {
        $this->sql_clauses_hash = md5( serialize( $sql_clauses ) );
    }

    /**
     * Parses a taxonomy query.
     *
     * @since 4.1.0
     * @access public
     *
     * @param array $q An array of taxonomy query parameters.
     * @return array
     */
    public function parse_query_vars( $q ) {
        $this->queries = $this->transform_query( $q );
        return $this->queries;
    }

    /**
     * Transforms old-style taxonomy queries to the format introduced in WP 3.1.
     *
     * @since 4.1.0
     * @access public
     *
     * @param array $tax_query An array of taxonomy query parameters.
     * @return array
     */
    public function transform_query( $tax_query ) {
        $relation = 'AND';
        if ( isset( $tax_query['relation'] ) ) {
            $relation = strtoupper( $tax_query['relation'] );
            if ( ! in_array( $relation, array( 'AND', 'OR' ), true ) ) {
                $relation = 'AND';
            }
        }

        $cleaned_query = array(
            'relation' => $relation,
        );

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

            if ( ! is_array( $clause ) ) {
                continue;
            }

            // 'taxonomy' is required, but may be absent for backward compatibility.
            if ( ! isset( $clause['taxonomy'] ) ) {
                continue;
            }

            $cleaned_query[] = $clause;
        }

        return $cleaned_query;
    }
}

可以看到,WP_Tax_Query 类主要包含以下几个部分:

  • $queries: 一个数组,存储了多个分类法查询条件。 每个查询条件都是一个数组,包含 taxonomy(分类法名称)、terms(术语 ID 或 slug)、field(字段,比如 term_idslugname)、operator(操作符,比如 INNOT INAND)等信息。
  • $relation: 一个字符串,表示多个查询条件之间的关系。 可以是 'AND'(所有条件都必须满足) 或 'OR'(满足任意一个条件即可)。
  • __construct(): 构造函数,接收一个数组作为参数,用于初始化 $queries$relation
  • get_sql(): 核心方法,负责生成 SQL 查询语句片段。 它会遍历 $queries 数组,将每个查询条件转换为 SQL 片段,然后根据 $relation 将这些片段组合起来。
  • get_sql_clauses(): get_sql() 的辅助方法,负责将 SQL 片段分为 joinwhere 两部分。 join 部分负责连接相关的数据库表,where 部分负责添加查询条件。
  • get_sql_for_query(): get_sql_clauses() 的辅助方法,负责将单个查询条件转换为 SQL 片段。

三、WP_Tax_Query 的使用方法

说了这么多,还是得看例子才能理解。 假设我们要查询属于 category 分类下的 newsfeatured 这两个 slug 的文章,并且属于 post_tag 标签下的 popular 这个 slug 的文章。

<?php
$args = array(
    'post_type' => 'post',
    'tax_query' => array(
        'relation' => 'AND', // 多个条件之间的关系是 AND
        array(
            'taxonomy' => 'category', // 分类法名称
            'field'    => 'slug',    // 字段,这里使用 slug
            'terms'    => array( 'news', 'featured' ), // 术语 slug
        ),
        array(
            'taxonomy' => 'post_tag', // 标签
            'field'    => 'slug',    // 字段,这里使用 slug
            'terms'    => array( 'popular' ), // 术语 slug
        ),
    ),
);

$query = new WP_Query( $args );

if ( $query->have_posts() ) {
    while ( $query->have_posts() ) {
        $query->the_post();
        echo '<p>' . get_the_title() . '</p>';
    }
    wp_reset_postdata();
} else {
    echo '<p>No posts found</p>';
}
?>

在这个例子中,我们创建了一个 WP_Query 对象,并将 tax_query 参数设置为一个数组。 这个数组包含了两个分类法查询条件,它们之间的关系是 AND

  • 第一个查询条件: 查询属于 category 分类下的 newsfeatured 这两个 slug 的文章。
  • 第二个查询条件: 查询属于 post_tag 标签下的 popular 这个 slug 的文章。

只有同时满足这两个查询条件,文章才会被查询出来。

再来一个例子,这次我们使用 OR 关系。 假设我们要查询属于 category 分类下的 newsfeatured 这两个 slug 的文章。

<?php
$args = array(
    'post_type' => 'post',
    'tax_query' => array(
        'relation' => 'OR', // 多个条件之间的关系是 OR
        array(
            'taxonomy' => 'category', // 分类法名称
            'field'    => 'slug',    // 字段,这里使用 slug
            'terms'    => array( 'news', 'featured' ), // 术语 slug
        ),
    ),
);

$query = new WP_Query( $args );

if ( $query->have_posts() ) {
    while ( $query->have_posts() ) {
        $query->the_post();
        echo '<p>' . get_the_title() . '</p>';
    }
    wp_reset_postdata();
} else {
    echo '<p>No posts found</p>';
}
?>

在这个例子中,只要文章属于 category 分类下的 newsfeatured 这两个 slug 中的任意一个,就会被查询出来。

四、WP_Tax_Query 的参数详解

WP_Tax_Query 的每个查询条件,都包含以下几个参数:

参数名 类型 描述
taxonomy string 分类法名称。 必须是已经注册的分类法,比如 categorypost_tag、自定义分类法等。
terms array 术语。 可以是术语 ID、slug 或 name,具体取决于 field 参数的设置。
field string 字段。 用于匹配 terms 参数的值。 可以是 term_id(术语 ID)、slug(术语 slug)、name(术语 name) 或 term_taxonomy_id (term taxonomy ID)。 默认为 term_id
operator string 操作符。 用于指定 terms 参数和数据库中的术语之间的关系。 可以是以下值:
* IN: 文章必须属于 terms 参数中的任意一个术语。
* NOT IN: 文章不能属于 terms 参数中的任意一个术语。
* AND: 文章必须同时属于 terms 参数中的所有术语。 注意:只有在 taxonomy 支持多个术语分配时,这个操作符才有效。 比如分类和标签都支持,但是自定义分类法可能不支持。
* EXISTS: 文章必须属于指定的分类法。 terms 参数会被忽略。
* NOT EXISTS: 文章不能属于指定的分类法。 terms 参数会被忽略。
* BETWEEN: 文章的 term_id 必须在 terms 参数指定的两个值之间。 必须是整数。
* NOT BETWEEN: 文章的 term_id 不在 terms 参数指定的两个值之间。 必须是整数。
include_children boolean 是否包含子术语。 默认为 true。 如果设置为 false,则只会查询属于指定术语的文章,不会查询属于其子术语的文章。这个参数只对层级分类法(比如分类)有效,对非层级分类法(比如标签)无效。

五、WP_Tax_Query 与数据库表的关系

WP_Tax_Query 在生成 SQL 查询语句时,主要涉及到以下三个数据库表:

  • wp_terms: 存储术语的信息,比如术语 ID、slug、name 等。
  • wp_term_taxonomy: 存储术语与分类法之间的关系,比如术语属于哪个分类法,以及术语的描述、父级术语等。
  • wp_term_relationships: 存储文章与术语之间的关系,比如文章属于哪个术语。

WP_Tax_Query 会根据查询条件,连接这些表,然后筛选出符合条件的文章。

六、 WP_Tax_Query 的 SQL 生成过程

我们来简化一下 SQL 生成的流程。

  1. 初始化: 接收 tax_query 参数(一个数组)并转换为 WP_Tax_Query 对象。
  2. 循环处理每个查询条件: 遍历 $queries 数组,对每个查询条件调用 get_sql_for_query() 方法。
  3. 构建单个查询条件的 SQL: 在 get_sql_for_query() 方法中,根据 taxonomyfieldtermsoperator 参数,生成 SQL 片段。
  4. 连接数据库表: 根据 taxonomy 参数,确定需要连接的数据库表。 通常需要连接 wp_term_relationships 表和 wp_term_taxonomy 表。 如果 field 参数是 slugname,还需要连接 wp_terms 表。
  5. 添加 WHERE 子句: 根据 termsoperator 参数,生成 WHERE 子句。 比如,如果 operatorIN,则生成 WHERE term_id IN (1, 2, 3) 这样的子句。
  6. 组合 SQL 片段: 将生成的 SQL 片段添加到 joinwhere 数组中。
  7. 处理多个查询条件: 如果 $queries 数组中包含多个查询条件,则根据 $relation 参数,将这些查询条件组合起来。 如果 $relationAND,则使用 AND 连接这些条件;如果 $relationOR,则使用 OR 连接这些条件。
  8. 返回 SQL: 将最终的 joinwhere 数组返回给 WP_Query

七、WP_Tax_Query 的高级用法

WP_Tax_Query 除了可以进行简单的分类法查询外,还可以进行一些更高级的操作。 比如:

  • 嵌套查询: 可以在 tax_query 中嵌套 tax_query,实现更复杂的查询逻辑。
  • 使用 EXISTSNOT EXISTS 操作符: 可以查询属于或不属于某个分类法的文章。
  • 自定义 SQL: 可以使用 get_tax_sql_joinget_tax_sql_where 过滤器,自定义 WP_Tax_Query 生成的 SQL。

八、WP_Tax_Query 的注意事项

  • WP_Tax_Query 的性能可能会受到查询条件的复杂程度的影响。 尽量避免使用过于复杂的查询条件,以提高查询效率。
  • WP_Tax_Query 的参数必须正确设置,否则可能会导致查询结果不正确。 仔细检查每个参数的值,确保它们符合预期。
  • WP_Tax_Query 与其他查询参数可能会产生冲突。 例如,如果同时使用了 category_nametax_query 参数,可能会导致查询结果不符合预期。 在使用 WP_Tax_Query 时,最好只使用 tax_query 参数,避免与其他参数产生冲突。

九、总结

WP_Tax_Query 是 WordPress 中一个非常强大的分类法查询工具。 它可以帮助我们构建复杂的分类法查询条件,实现各种各样的文章列表展示需求。 掌握 WP_Tax_Query 的使用方法,可以让我们更灵活地控制 WordPress 的文章查询。

好了,今天的 WP_Tax_Query 源码剖析就到这里。 希望大家能够有所收获,下次再见!

发表回复

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