Je query builder custom type
Skill Lonsdale201/wp-agent-skills/jet-engine/je-query-builder-custom-type
Register a custom Query type for JetEngine's Query Builder — extend \Jet_Engine\Query_Builder\Queries\Base_Query for the runtime query class, extend \Jet_Engine\Query_Builder\Query_Editor\Base_Query for the editor component, then hook BOTH register actions — jet-engine/query-builder/queries/register (factory) for the runtime, jet-engine/query-builder/query-editor/register for the editor UI. Five abstract methods on Base_Query — _get_items(), get_items_total_count(), get_items_page_count(), get_items_pages_count(), get_current_items_page(). Built-in cache via get_cached_data() / update_query_cache(). Custom queries participate in JE 3.8+ MCP tool exposure and the frontend query inspector automatically. Use when scaffolding a custom query type (HPOS WC orders, third-party API source, custom DB table) for JetEngine listings, dynamic widgets, or REST API endpoints.From its SKILL.md
npx -y skills add Lonsdale201/wp-agent-skills --skill je-query-builder-custom-typeAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 21 stars21 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
39.0 KB, ~9.2k tokens by cl100k_base, as published. Nobody here has run it
JetEngine: register a custom Query Builder query type
For developers adding a new query type to JetEngine's Query Builder — query data sources beyond the built-in Posts / Terms / Users / Comments / SQL / Repeater / Current_WP_Query / Merged_Query. Common cases: HPOS WooCommerce orders (the legacy Posts_Query won't see HPOS-stored orders), custom DB tables, external REST API responses, computed result sets. Once registered, your query appears in the Query Builder editor, listings can iterate it, dynamic widgets can target it, and (since JE 3.8) the MCP tool surface and the frontend query inspector automatically include it.
Misconception this skill corrects
"I'll write a custom listing type with
WP_Queryoverrides — same outcome."
Wrong layer. JetEngine's Query Builder is a separate system from WP_Query / pre_get_posts. The Query Builder is a stored, named, reusable query (saved as a jet-engine-query post type entry, addressable by ID across listings, widgets, and REST endpoints). Custom listing-type overrides give you ONE listing's data; a custom query type gives you a named query you can use FROM many listings + the dynamic widgets + REST.
The verified extension contract is two coupled hooks:
// 1. Register the runtime query class (the data source)
add_action( 'jet-engine/query-builder/queries/register', function ( $factory ) {
$factory::register_query( 'my-custom-type', \MyPlugin\Query\MyCustomQuery::class );
} );
// 2. Register the editor UI for it (the form fields admins fill in)
add_action( 'jet-engine/query-builder/query-editor/register', function ( $manager ) {
$manager->register_type( new \MyPlugin\Query\MyCustomQueryEditor() );
} );
The two MUST be paired. Verified at wp-content/plugins/jet-engine/includes/components/query-builder/query-factory.php:128 (factory hook fires after default queries register) and includes/components/query-builder/query-editor.php:54 (editor hook fires after default editors register).
Other AI-prone misconceptions:
- "I'll use
pre_get_poststo inject HPOS support into the Posts_Query." Won't work —Posts_Querybuilds aWP_Queryover thewp_poststable; HPOS orders live inwp_wc_orders. A new query type extendingBase_Queryand usingWC_Order_Queryis the right path (and what the existingdynamic-elementor-extensionplugin'sWCOrderHposQuerydoes). - "The query class can be just a function or POPO." No — it MUST extend
Base_QueryAND implement five abstract methods:_get_items(),get_items_total_count(),get_items_page_count(),get_items_pages_count(),get_current_items_page(). Verified at queries/base.php:638-666. - "Cache is automatic." Half-true —
Base_Queryprovides a cache layer (get_cached_data/update_query_cache) but YOUR_get_items()/get_items_total_count()need to call it. The factory wires the keys; you're responsible for cache use.
When to use this skill
Trigger when ANY of the following is true:
- The diff hooks
jet-engine/query-builder/queries/registerorjet-engine/query-builder/query-editor/register. - The diff extends
\Jet_Engine\Query_Builder\Queries\Base_Queryor\Jet_Engine\Query_Builder\Query_Editor\Base_Query. - The user wants a query source JE doesn't ship: HPOS orders, custom DB tables, external API, derived/computed sets.
- A listing or dynamic widget needs to iterate a non-standard data source via the Query Builder.
The contract — two classes, two hooks
Runtime: Queries\Base_Query
| Method | Required? | Purpose |
|---|---|---|
_get_items() | abstract | Return the array of items for the current page. |
get_items_total_count() | abstract | Total count across ALL pages. |
get_items_page_count() | abstract | Items returned on the CURRENT page (after pagination + per-page). |
get_items_pages_count() | abstract | Total page count. |
get_current_items_page() | abstract | Current page number (1-indexed). |
__construct( $args = [] ) | provided | Sets $id, $name, $query_id, $query_type, $query, $dynamic_query, $preview, $cache_query, $cache_expires, $api_settings. |
get_cached_data( $key = null ) | provided | Read from object cache (per-query-instance cache hash). |
update_query_cache( $data, $key = null ) | provided | Write to cache. |
setup_query() | provided, overridable | The "rebuild internal state from $this->final_query" lifecycle hook — override to set up your underlying query object. |
reset_query() | provided, overridable | Reset internal state — for second runs of the same query in one request. |
get_query_args() | provided | The current resolved $this->final_query array. |
apply_macros( $val ) | provided | Resolve %dynamic% macros in a value (use for user-entered fields). |
merge_dynamic_nested_args( $args_group ) | provided | Combine static query settings with dynamic_query overrides. |
Editor: Query_Editor\Base_Query
| Method | Required? | Purpose |
|---|---|---|
get_id() | abstract | Slug — MUST match the runtime query's registered type. |
get_name() | abstract | Display label in the editor's "Query Type" dropdown. |
editor_component_name() | optional | Vue component name for the editor form (default null = use generic). |
editor_component_template() | optional | Vue template string. |
editor_component_file() | optional | Path to a Vue component file (cleaner). |
editor_component_data() | optional | Initial data passed to the Vue component (default values, options arrays). |
The editor base lives at includes/components/query-builder/editor/base.php:4. Verified.
Workflow
1. Bootstrap your companion plugin
add_action( 'plugins_loaded', static function (): void {
if ( ! class_exists( '\Jet_Engine' ) ) {
return; // JE not active
}
add_action(
'jet-engine/query-builder/queries/register',
[ \MyPlugin\Query\Manager::class, 'register_runtime' ]
);
add_action(
'jet-engine/query-builder/query-editor/register',
[ \MyPlugin\Query\Manager::class, 'register_editor' ]
);
}, 11 );
JetEngine's Query Builder doesn't have a separate module flag — it's part of core; just check class_exists('\Jet_Engine'). Priority 11 so JE's own bootstrap runs first.
2. Runtime query class — minimal HPOS WC orders example
namespace MyPlugin\Query;
use Jet_Engine\Query_Builder\Queries\Base_Query;
class WCOrderHposQuery extends Base_Query {
private ?\WC_Order_Query $current_query = null;
private ?array $current_results = null;
private bool $is_paginated = false;
private int $items_per_page = 0;
public function _get_items(): array {
$results = $this->get_results();
return $results['orders'];
}
public function get_items_total_count(): int {
$cached = $this->get_cached_data( 'count' );
if ( false !== $cached ) {
return (int) $cached;
}
$results = $this->get_results();
$total = $results['total'];
$this->update_query_cache( $total, 'count' );
return $total;
}
public function get_current_items_page(): int {
if ( ! $this->is_paginated ) {
return 1;
}
$query = $this->get_current_order_query();
$page = $query ? absint( $query->get( 'page' ) ) : 1;
return $page > 0 ? $page : 1;
}
public function get_items_pages_count(): int {
if ( $this->is_paginated ) {
$results = $this->get_results();
$pages = $results['max_num_pages'];
return $pages > 0 ? $pages : 1;
}
$per_page = $this->get_items_per_page();
$total = $this->get_items_total_count();
return $per_page ? (int) ceil( $total / $per_page ) : 1;
}
public function get_items_page_count(): int {
if ( ! $this->is_paginated ) {
return $this->get_items_total_count();
}
$items = $this->_get_items();
return is_array( $items ) ? count( $items ) : 0;
}
/** Build / cache / return the WC_Order_Query result. */
private function get_results(): array {
if ( null !== $this->current_results ) {
return $this->current_results;
}
$cached = $this->get_cached_data( 'results' );
if ( false !== $cached ) {
$this->current_results = $cached;
return $cached;
}
$args = $this->build_args();
$this->current_query = new \WC_Order_Query( $args );
$orders = $this->current_query->get_orders();
$total = $this->current_query->get_total();
$this->current_results = [
'orders' => $orders,
'total' => $total,
'max_num_pages' => $this->items_per_page
? (int) ceil( $total / $this->items_per_page )
: 1,
];
$this->update_query_cache( $this->current_results, 'results' );
return $this->current_results;
}
private function build_args(): array {
$query = (array) ( $this->final_query ?: [] );
$merged = $this->merge_dynamic_nested_args( $query ); // dynamic args override
// your translation from JE settings → WC_Order_Query args
return [
'limit' => $merged['posts_per_page'] ?? 10,
'page' => $merged['paged'] ?? 1,
'paginate' => true,
'status' => $merged['order_status'] ?? array_keys( wc_get_order_statuses() ),
// ...
];
}
private function get_current_order_query(): ?\WC_Order_Query {
if ( null === $this->current_query ) {
$this->get_results();
}
return $this->current_query;
}
}
3. Editor class — minimal companion
namespace MyPlugin\Query;
use Jet_Engine\Query_Builder\Query_Editor\Base_Query as Editor_Base_Query;
class WCOrderHposQueryEditor extends Editor_Base_Query {
public function get_id(): string {
return 'wc-order-hpos'; // MUST match the runtime registration slug
}
public function get_name(): string {
return __( 'WooCommerce Orders (HPOS)', 'myplugin' );
}
/**
* For complex editors, point at a Vue component file shipped with your plugin.
* For simple types, leave the defaults — the generic editor handles basic args.
*/
public function editor_component_file(): string {
return MYPLUGIN_PATH . '/assets/js/query-editor.js';
}
public function editor_component_data(): array {
return [
'orderStatusOptions' => array_map(
static fn ( $label ) => [ 'value' => $label, 'label' => $label ],
wc_get_order_statuses()
),
];
}
}
4. Manager — wire both registrations
namespace MyPlugin\Query;
class Manager {
private const QUERY_SLUG = 'wc-order-hpos';
public static function register_runtime( $factory ): void {
// $factory is the FQN of Query_Factory (passed via get_called_class()).
// Call register_query() statically:
$factory::register_query( self::QUERY_SLUG, WCOrderHposQuery::class );
}
public static function register_editor( $manager ): void {
$manager->register_type( new WCOrderHposQueryEditor() );
}
}
The factory hook passes the class name (string) — that's why you call $factory::register_query( ... ) statically. Verified at query-factory.php:127 — do_action( 'jet-engine/query-builder/queries/register', get_called_class() ).
5. The final_query vs query vs dynamic_query distinction
Three properties on Base_Query look related but mean different things:
| Property | Source | Meaning |
|---|---|---|
$this->query | constructor arg query | The user-saved STATIC query settings (from the editor form). |
$this->dynamic_query | constructor arg dynamic_query | DYNAMIC overrides resolved at runtime — e.g. "use the current page's category as the term filter". Each entry has a path + dynamic value. |
$this->final_query | setup_query() result | The merged, resolved query — what your build_args() should read from. |
Use merge_dynamic_nested_args() to combine them correctly. Don't read $this->query directly when you have dynamic args — the static value would override the dynamic resolution. Verified at queries/base.php:410.
6. Cache discipline
The cache layer keys per-instance via a hash of get_query_hash_args(). It does NOT cache automatically — your code must call:
public function get_items_total_count(): int {
$cached = $this->get_cached_data( 'count' );
if ( false !== $cached ) {
return (int) $cached;
}
// ... expensive count query ...
$this->update_query_cache( $total, 'count' );
return $total;
}
The 'count' key is a sub-key WITHIN the per-instance hash bucket — so you can cache 'count', 'results', 'whatever' separately. Cache disabled if $this->cache_query === false (user setting in the editor).
7. Macros and dynamic values
User-entered query args may contain JE macros like %current_user_id% or %queried_post_meta|some_field%. Resolve them via:
$user_id = $this->apply_macros( $merged['author'] ?? '' );
apply_macros() walks the JE macro engine and returns the resolved string. Don't use raw user input — macros won't resolve and you'll see literal %foo% in your query args.
8. REST API endpoint exposure (optional)
Each query saved in JE can be exposed as a REST endpoint via the api_settings constructor args. Your query class participates automatically — Base_Query::__construct() reads the endpoint config. You don't need to register routes manually; JE's Query_Endpoint does it from the saved query data.
Verified at queries/base.php:39-46 — api_settings array stores api_endpoint, api_namespace, api_path, api_access, api_access_cap, api_access_role, api_schema. The maybe_register_rest_api_endpoint() method at base.php:68 wires it up.
9. MCP tool exposure (JE 3.8+)
JE 3.8 added an MCP bridge (includes/components/query-builder/mcp/) that exposes saved queries as tools to AI agents (Claude / GPT via MCP server). Your custom query type participates AUTOMATICALLY — the MCP layer reads from the registered query types and surfaces them as tools. No additional registration required, but ensure your _get_items() returns a SERIALIZABLE shape (objects must be JsonSerializable or arrays).
10. Reusing JE's Meta Query / Date Query / Tax Query group UI
The hardest UI to roll yourself is the Meta Query repeater with its group support ("Add new" for a leaf clause + "Add new group" for a nested AND/OR sub-query). JE ships the entire stack as reusable building blocks. You don't author the meta-clause editor — you compose JE's existing pieces.
The same pattern applies to Date Query (Date_Query_Trait + JetQueryDateParamsMixin) and Tax Query (JetQueryTaxParamsMixin). Below is the meta-query flow end-to-end; date/tax follow the same shape.
a) Editor template — the Meta Query tab
<cx-vui-tabs-panel
name="meta_query"
:label="isInUseMark( [ 'meta_query' ] ) + '<?php esc_attr_e( 'Meta Query', 'mytype' ); ?>'"
key="meta_query"
>
<cx-vui-component-wrapper :wrapper-css="[ 'query-fullwidth' ]">
<div class="cx-vui-inner-panel query-panel">
<div class="cx-vui-component__label"><?php esc_html_e( 'Meta Query Clauses', 'mytype' ); ?></div>
<cx-vui-repeater
button-label="<?php esc_attr_e( 'Add new', 'mytype' ); ?>"
button-style="accent"
button-size="mini"
v-model="query.meta_query"
@add-new-item="addNewField( $event, [], query.meta_query, newDynamicMeta )"
:custom-actions="[
{
buttonLabel: '<?php esc_attr_e( 'Add new group', 'mytype' ); ?>',
buttonStyle: 'accent-border',
callback: addNewMetaGroup,
}
]"
>
<cx-vui-repeater-item
v-for="( clause, index ) in query.meta_query"
:collapsed="isCollapsed( clause )"
:index="index"
:key="clause._id"
@clone-item="cloneField( $event, clause._id, query.meta_query, newDynamicMeta )"
@delete-item="deleteField( $event, clause._id, query.meta_query, deleteDynamicMeta )"
>
<jet-engine-query-meta-field
:field="clause"
:meta-query="query.meta_query"
:dynamic-query="dynamicQuery.meta_query[ clause._id ]"
@input="setFieldData( clause._id, $event, query.meta_query )"
@dynamic-input="setDynamicMeta( clause._id, $event )"
></jet-engine-query-meta-field>
</cx-vui-repeater-item>
</cx-vui-repeater>
</div>
</cx-vui-component-wrapper>
<cx-vui-select
v-if="1 < query.meta_query.length"
label="<?php esc_attr_e( 'Relation', 'mytype' ); ?>"
:wrapper-css="[ 'equalwidth' ]"
:options-list="[
{ value: 'and', label: '<?php esc_attr_e( 'And', 'mytype' ); ?>' },
{ value: 'or', label: '<?php esc_attr_e( 'Or', 'mytype' ); ?>' },
]"
size="fullwidth"
v-model="query.meta_query_relation"
></cx-vui-select>
</cx-vui-tabs-panel>
The <jet-engine-query-meta-field> component is registered globally by JE itself (mixins.js:142-238) and the #jet-meta-field template is printed in the JE query-edit page footer (pages/edit.php:236). You don't load either — they're already on the page when your editor mounts.
b) JS — pull in the mixins, preset state
Vue.component( 'jet-mytype-query', {
template: '#jet-mytype-query',
mixins: [
window.JetQueryWatcherMixin,
window.JetQueryRepeaterMixin,
window.JetQueryMetaParamsMixin, // <-- gives presetMeta, newDynamicMeta, addNewMetaGroup, deleteDynamicMeta
],
props: [ 'value', 'dynamic-value' ],
data: function () {
return {
operators: window.JetEngineQueryConfig.operators_list, // shared globals
dataTypes: window.JetEngineQueryConfig.data_types,
query: {},
dynamicQuery: {},
};
},
computed: {
// Optional — exposes "named clauses" so other parts of the editor can reference them
metaClauses: function () {
const result = [];
for ( let i = 0; i < this.query.meta_query.length; i++ ) {
if ( this.query.meta_query[ i ].clause_name ) {
result.push( {
value: this.query.meta_query[ i ].clause_name,
label: this.query.meta_query[ i ].clause_name,
} );
}
}
return result;
},
},
created: function () {
this.query = { ...this.value };
this.dynamicQuery = { ...this.dynamicValue };
this.presetMeta(); // <-- initializes query.meta_query = [] and dynamicQuery.meta_query = {}
},
} );
presetMeta() ensures query.meta_query is an array and dynamicQuery.meta_query is an object map keyed by clause _id. Without it, the first "Add new" click crashes because meta_query is undefined.
addNewMetaGroup (provided by JetQueryMetaParamsMixin) appends a SPECIAL clause with is_group: true, relation: 'and', args: []. The <jet-engine-query-meta-field> component detects is_group and renders the nested repeater inside it automatically — you don't write group-rendering UI.
c) Runtime — Meta_Query_Trait translates the structure to a WP-compatible meta_query
class MyTypeQuery extends \Jet_Engine\Query_Builder\Queries\Base_Query {
use \Jet_Engine\Query_Builder\Queries\Traits\Meta_Query_Trait;
public function get_query_args() {
if ( null === $this->final_query ) {
$this->setup_query();
}
$args = $this->final_query;
if ( ! empty( $args['meta_query'] ) ) {
// Trait converts editor structure (clauses + groups + relation + clause_name)
// into the canonical WP_Query meta_query shape, recursively for groups.
$args['meta_query'] = $this->prepare_meta_query_args( $args );
}
return $args;
}
}
Meta_Query_Trait::prepare_meta_query_args() (traits/meta-query.php:12) does several non-obvious transformations — DON'T re-implement these by hand:
- Recurses into
$row['is_group']clauses and produces nested[ 'relation' => 'AND', [...sub-clauses...] ]blocks. - Top-level
meta_query_relationbecomesmeta_query['relation']. IN/NOT INoperator with a comma-separated string value gets exploded into an array.TIMESTAMPdata-type values get converted to NUMERIC + epoch viastrtotime().exclude_emptyflag drops the clause entirely when its value is empty (different from theEXISTS/NOT EXISTSsemantics).- Clauses with
clause_nameget keyed by that name (soorderbycan targetmeta_valueof a specific clause). custom: trueclauses (added at runtime by filter integration) get appended after the editor-defined ones, with relation-aware placement.
d) Filter integration — set_filtered_prop( 'meta_query', $rows )
When a JE filter widget (search, taxonomy, range slider) injects extra meta criteria at runtime, JE calls set_filtered_prop( 'meta_query', $rows ) on your query. Wire it to replace_meta_query_row() (also from the trait):
public function set_filtered_prop( $prop = '', $value = null ) {
switch ( $prop ) {
case 'meta_query':
$this->replace_meta_query_row( $value );
break;
// ... other props
default:
$this->merge_default_props( $prop, $value );
break;
}
}
replace_meta_query_row() (traits/meta-query.php:98) merges the filter-supplied rows with the existing editor-defined rows — replacing rows that share a key and appending the rest as custom: true. This is what lets a search-filter widget refine an existing meta_query without clobbering it.
e) Where to call prepare_meta_query_args() matters
Call it INSIDE get_query_args() (or wherever you build the args you pass to your data source) — NOT in _get_items(). The reason — JE's filter integration calls set_filtered_prop() which writes to $this->final_query['meta_query'] in editor format. If you transform too early, the filter writes look corrupted; if you transform too late, the data source receives editor format instead of WP format. The Users / Comments / Posts queries all do it in get_query_args() — follow the convention.
// WRONG — runtime data source receives editor-format meta_query, not WP_Query format
public function _get_items() {
return wc_memberships_get_membership_plans( $this->final_query );
}
// RIGHT — transform on the way out
public function _get_items() {
return wc_memberships_get_membership_plans( $this->get_query_args() );
}
Where get_query_args() calls $this->prepare_meta_query_args( $args ) before returning.
Critical rules
- Two hooks paired.
jet-engine/query-builder/queries/registerANDjet-engine/query-builder/query-editor/register— never just one. - Slug match. The runtime registration slug and the editor's
get_id()MUST be identical strings, otherwise the editor can't bind to the runtime class. - Five abstract methods on
Base_Query._get_items(),get_items_total_count(),get_items_page_count(),get_items_pages_count(),get_current_items_page(). Skipping any → fatal. - Read from
$this->final_queryin yourbuild_args()/setup_query(). Usemerge_dynamic_nested_args()to get the resolved version including dynamic overrides. - Cache is opt-in via
get_cached_data/update_query_cache. Use it for expensive counts and full results. - Honor
$this->cache_queryand$this->cache_expires. Don't bypass when user disabled cache in the editor. apply_macros()for user-entered fields that may contain%macro%placeholders. Don't push raw user strings into your query args.- Singleton-friendly construction.
Base_Query::__construct( $args = [] )— your subclass either passes through to parent or extends with its own state. Don't replace the parent constructor. - Cache key sub-keys are arbitrary. Use distinct keys (
'count','results','top-N') per cached operation to avoid stomping. - MCP / REST surfaces are automatic. Don't try to register them again; JE handles per saved-query.
- Hook at priority 11+ on
plugins_loadedso JE's bootstrap runs first. - Reuse
Meta_Query_Trait/Date_Query_Trait/Tax_Query_Traitinstead of hand-rolling editor-format → WP-format conversion. The traits handle nested groups, OR/AND relations, IN/NOT IN explosion, TIMESTAMP coercion, and clause naming — easy to get wrong if you write your own. - Reuse
JetQueryMetaParamsMixin+<jet-engine-query-meta-field>in the editor. Don't author the meta-clause UI yourself. The component is registered globally on the JE query-edit page, the#jet-meta-fieldtemplate is in the page footer, and the mixin gives youpresetMeta,newDynamicMeta,addNewMetaGroup,deleteDynamicMetaready to wire to<cx-vui-repeater>. - Always call
presetMeta()/presetDate()/presetTax()increatedif you use the corresponding mixin. Without it, the structure is undefined on first paint and the first "Add new" click throws. - Transform meta_query in
get_query_args(), not in_get_items(). Filter widgets write to$this->final_query['meta_query']in editor format; transform on the way out, not on the way in. _e()notesc_attr_e()inside Vue binding JS string literals.esc_attr_e()encodes'→'which Vue's template parser then HTML-decodes back to', closing the JS string early and throwingReferenceErroron the next bare identifier. This applies to:label="... + '...'",:options-list="[ { label: '...' } ]",:custom-actions="[ { buttonLabel: '...' } ]". Plain HTML attributes (label="...") still useesc_attr_e().- Pre-register your editor script with an explicit
jet-engine-query-mixinsdependency. JE enqueues type-component scripts with empty deps (query-editor.php:108), which leaves the load order undefined relative tomixins.js. Result:window.JetQueryMetaParamsMixinmay be undefined atVue.component()definition time, the mixin silently fails to apply, and the first render throwsReferenceError: addNewMetaGroup is not defined(or whichever mixin method the template touches first). JE's own type scripts ship in the same plugin and don't hit this — third-party plugins do. Hookjet-engine/query-builder/editor/before-enqueue-scriptsat priority 5 (before JE's own callback at default priority 10) and callwp_register_script()with your handle (jet-query-component-{slug}), your src, and[ 'jet-engine-query-mixins' ]as deps. JE's laterwp_enqueue_script()call with empty deps is a no-op on the already-registered handle — the enqueue happens, your deps stick.
Common mistakes
// WRONG — only registering the runtime, not the editor
add_action( 'jet-engine/query-builder/queries/register', function ( $factory ) {
$factory::register_query( 'my-type', MyQuery::class );
} );
// (no jet-engine/query-builder/query-editor/register hook)
// → query type works at runtime, but admins can't pick it from the editor → unusable.
// RIGHT — both
add_action( 'jet-engine/query-builder/queries/register', /* runtime */ );
add_action( 'jet-engine/query-builder/query-editor/register', /* editor */ );
// WRONG — slug mismatch
$factory::register_query( 'wc-orders-hpos', MyQuery::class ); // runtime
$manager->register_type( new MyEditor() ); // editor get_id() returns 'wc-order-hpos'
// → editor renders, but runtime never matches; saved query is broken.
// RIGHT — same slug everywhere
private const QUERY_SLUG = 'wc-order-hpos';
$factory::register_query( self::QUERY_SLUG, MyQuery::class );
// WRONG — implementing fewer than 5 abstract methods
class MyQuery extends Base_Query {
public function _get_items() { /* ... */ }
public function get_items_total_count() { /* ... */ }
// (missing get_items_page_count / get_items_pages_count / get_current_items_page)
}
// → fatal: cannot instantiate abstract class.
// RIGHT — implement all 5
// WRONG — reading $this->query directly
public function build_args() {
$args = (array) $this->query; // WRONG: ignores dynamic overrides
return $args;
}
// RIGHT
public function build_args() {
$args = $this->merge_dynamic_nested_args( (array) $this->final_query );
return $args;
}
// WRONG — caching bypassed
public function get_items_total_count() {
return $this->expensive_count_query(); // re-runs every page render
}
// RIGHT
public function get_items_total_count() {
$cached = $this->get_cached_data( 'count' );
if ( false !== $cached ) return (int) $cached;
$count = $this->expensive_count_query();
$this->update_query_cache( $count, 'count' );
return $count;
}
// WRONG — manual macro resolution
public function build_args() {
$author = preg_replace( '/^%(.+)%$/', '$1', $this->final_query['author'] ?? '' );
// WRONG: won't resolve nested macros like %queried_post_meta|some_field%
}
// RIGHT
public function build_args() {
$author = $this->apply_macros( $this->final_query['author'] ?? '' );
}
// WRONG — registering at the wrong action priority
add_action( 'init', function () {
add_action( 'jet-engine/query-builder/queries/register', /* ... */ );
} );
// init fires AFTER plugins_loaded; JE's ensure_queries() may have run by then.
// RIGHT — register the registration hook on plugins_loaded:11
add_action( 'plugins_loaded', function () {
add_action( 'jet-engine/query-builder/queries/register', /* ... */ );
}, 11 );
// WRONG — non-serializable result for MCP
public function _get_items() {
return $this->wc_orders; // array of WC_Order objects → not JSON-serializable for MCP layer
}
// RIGHT — convert for the consumer surface OR implement JsonSerializable
public function _get_items() {
return array_map(
fn ( \WC_Order $o ) => $o->get_data(), // associative array, JSON-safe
$this->wc_orders
);
}
// WRONG — overriding constructor without calling parent
public function __construct( $args = [] ) {
$this->slug = $args['slug'] ?? '';
// (no parent::__construct( $args ))
}
// → $this->id, $this->query_type, etc. all unset → JE downstream code breaks.
// RIGHT
public function __construct( $args = [] ) {
parent::__construct( $args );
$this->slug = $args['slug'] ?? '';
}
// WRONG — assuming setup_query auto-runs
public function _get_items() {
return $this->wc_orders; // wc_orders never populated; setup_query never ran
}
// RIGHT — setup_query is invoked by JE before _get_items, BUT only if you handle the trigger:
public function setup_query() {
parent::setup_query(); // resolves $this->final_query from $this->query + dynamic_query
$this->wc_orders = $this->execute_wc_query();
}
// WRONG — esc_attr_e() inside a JS string literal in a Vue binding expression
// editor template (PHP):
:custom-actions="[
{
buttonLabel: '<?php esc_attr_e( 'Add new group', 'mytype' ); ?>',
callback: addNewMetaGroup,
}
]"
// WRONG: esc_attr_e encodes ' → ', so the rendered output is:
// buttonLabel: ''Add new group'',
// Vue's template parser HTML-decodes this to:
// buttonLabel: ''Add new group'',
// which closes the string early. The next bare identifier (Add, addNewMetaGroup, etc.)
// then throws ReferenceError at render time. JE's own templates use _e() here.
// RIGHT — _e() echoes raw text into a JS string literal context
:custom-actions="[
{
buttonLabel: '<?php _e( 'Add new group', 'mytype' ); ?>',
callback: addNewMetaGroup,
}
]"
// Same rule applies to :options-list="[ { label: '...' } ]" and
// :label="isInUseMark(...) + '...'" — anywhere a Vue binding expression contains
// a single-quoted JS string with translated content. Plain HTML attributes
// (label="...", description="...") still use esc_attr_e() — they're not JS expressions.
Cross-references
- Run
je-dynamic-visibility-conditionfor the visibility-gate pattern (different extension surface; both are JE registration hooks). - Run
wc-hpos-compatibilitywhen your custom query handles WooCommerce orders — HPOS migration concerns. - Run
wp-rest-apiif you're consuming the auto-exposed REST endpoint that JE creates from your saved query. - Run
wp-plugin-architecturefor the companion plugin scaffold +plugins_loaded:11priority pattern.
What this skill does NOT cover
- Vue editor component authoring. The
editor_component_file()returns a path to your Vue component, but the component itself uses JE's query-editor Vue framework — out of scope. For complex editors, copy the JE built-in editor components as a template. - Listing-side rendering. This skill is about the QUERY; once the query returns items, JE listings / dynamic widgets render them via separate templates.
- REST API auth on the auto-generated endpoint. Use
api_access/api_access_capsettings in the saved query'sapi_settings. The endpoint is created automatically; auth is editor-configured. - MCP tool descriptor customization. JE auto-generates the MCP tool from your query type; tweaking the descriptor isn't supported as a plugin extension point.
- Frontend query inspector UI. Available since JE 3.8 (
jet-engine/query-builder/frontend-editor/is-enabledfilter); your custom queries appear automatically. UI customization not in scope. - Replacing built-in queries. No way to "override" the built-in
Posts_Query; create a new type with a unique slug. Avoid_Duplicatesintegration. JE has a duplicate-prevention layer for cross-query consistency — your custom query may need to opt in via thequery_was_changedlifecycle hook for advanced cases.
References
- Runtime base: wp-content/plugins/jet-engine/includes/components/query-builder/queries/base.php:6 —
abstract class Base_Query. Constructor at line 27, abstract methods at 638-666 (_get_items,get_items_total_count,get_items_page_count,get_items_pages_count,get_current_items_page). Cache helpers at 141-178.setup_query()at 267,merge_dynamic_nested_args()at 410,apply_macros()at 488,get_query_args()at 553. - Reference runtime impl: includes/components/query-builder/queries/posts.php —
Posts_Query extends Base_Query, full pattern withWP_Queryunderneath. - Query factory: includes/components/query-builder/query-factory.php:128 —
do_action( 'jet-engine/query-builder/queries/register', get_called_class() )afterensure_queries().register_query( $type, $class )at line 142. - Default query types map: query-factory.php:92-103 —
get_default_query_types()returns the 8 built-ins. - Editor manager: includes/components/query-builder/query-editor.php:54 —
do_action( 'jet-engine/query-builder/query-editor/register', $this )after default editors register.register_type()after. - Editor base: includes/components/query-builder/editor/base.php:4 —
abstract class Base_Querywithget_id()/get_name()abstract,editor_component_*optional. - Manager (orchestrator): includes/components/query-builder/manager.php — fires
jet-engine/query-builder/init,jet-engine/query-builder/after-queries-setup,jet-engine/query-builder/<for>/orderby-optionsfilters. - Meta query trait (runtime): includes/components/query-builder/queries/traits/meta-query.php:12 —
prepare_meta_query_args()recurses groups, handlesIN/NOT INexplosion,TIMESTAMPcoercion,clause_namekeying,exclude_emptyskip.replace_meta_query_row()at line 98 for filter integration. - Date / Tax traits: includes/components/query-builder/queries/traits/date-query.php, tax-query.php — same shape as meta but for
WP_Date_Query/WP_Tax_Queryarg formats. - Editor mixins: includes/components/query-builder/assets/js/admin/mixins.js —
JetQueryWatcherMixin(line 285),JetQueryRepeaterMixin(line 35),JetQueryMetaParamsMixin(line 240, givespresetMeta/newDynamicMeta/addNewMetaGroup/deleteDynamicMeta),JetQueryDateParamsMixin(line 302),JetQueryTaxParamsMixin(line 337),JetQueryTabInUseMixin(line 4, givesisInUseMark). - Meta-field component: mixins.js:142-238 —
JetEngineQueryMetaFieldregistered as<jet-engine-query-meta-field>. Renders one meta clause OR a group (auto-detectsis_group: true). Template id#jet-meta-fieldprinted by pages/edit.php:236 — already on the JE query-edit page; you don't load it. - Reference editor with meta-query group setup: includes/components/query-builder/templates/admin/types/users.php:194-254 and the matching JS at assets/js/admin/types/users.js:5-58. Cleaner than the Posts editor for studying the pattern.
- Editor data globals: includes/components/query-builder/pages/edit.php:188-218 —
JetEngineQueryConfigwindow global is localized here withoperators_list,data_types,orderby_options,post_types. Reference these from your editor JS instead of re-localizing. - Crocoblock developer documentation: https://github.com/Crocoblock/developer-documentation/tree/main/01-jet-engine.
- Official documentation: https://crocoblock.com/knowledge-base/plugins/jetengine/
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.