Skip to content

Repository files navigation

Queryable

A fluent SQL query builder for WordPress. Built on $wpdb, with meta table support, relations, migrations, and a typed model layer

Installation

composer require alaca/queryable

Two ways to use

1. Query Builder (DB Facade)

Use DB::table() for direct queries

use Queryable\DB;

// Simple queries
$posts = DB::table('posts')->select('ID', 'post_title')->where('post_status', 'publish')->getAll();
$user  = DB::table('users')->find('ID', 1);

// If you want to work with meta and relations using DB class, you will have to configure the schema in DB::init()
DB::init([
    'schema' => [
        'posts' => [
            'meta' => [
                'table' => 'postmeta', // table name is auto prefixed
                'foreignKey' => 'post_id',
                'primaryKey' => 'ID',
                'aliases' => [
                    'price' => '_product_price',
                    'color' => '_product_color',
                ],
            ],
            'relations' => [
                'comments' => [
                    'table' => 'comments',
                    'foreignKey' => 'comment_post_ID',
                    'primaryKey' => 'ID',
                    'type' => 'hasMany',
                ],
            ],
        ],
    ],
]);

$products = DB::table('posts')
    ->select('ID', 'post_title')
    ->withMeta('price', 'color')
    ->where('post_type', 'product')
    ->orderBy('price', 'DESC')
    ->getAll();

2. Model (Recommended)

Define a model class per table with typed public properties

use Queryable\Model;
use Queryable\Schema\Table;

class Campaign extends Model
{
    protected string $table = 'campaigns';
    protected string $version = '1.0.0';

    public int $id;
    public string $name;
    public string $slug;
    public float $price;
    public int $stock;
    public ?string $created_at = null;

    protected function meta(): array
    {
        return [
            'table' => 'campaign_meta',
            'foreignKey' => 'campaign_id',
            'primaryKey' => 'id',
            'aliases' => [
                'budget' => '_campaign_budget',
                'channel' => '_campaign_channel',
                'tags' => [
                    'key' => '_campaign_tags', 
                    'multiple' => true
                ],
            ],
        ];
    }

    protected function relations(): array
    {
        return [
            'entries' => [
                'table' => 'campaign_entries',
                'foreignKey' => 'campaign_id',
                'primaryKey' => 'id',
                'type' => 'hasMany',
            ],
        ];
    }
}

// Schema is defined separately
Campaign::schema(function (Table $table) {
    $table->id();
    $table->string('name');
    $table->string('slug', 100)->unique();
    $table->decimal('price', 8, 2)->default(0);
    $table->integer('stock')->default(0);
    $table->datetime('created_at')->nullable();
});

// Query results are Campaign instances
$campaign = Campaign::query()->find('slug', 'summer');
$campaign instanceof Campaign; // true
$campaign->name;
$campaign->price; // float, auto-cast from DB  - should be unsigned int in real app! 

Campaign::query()->select('id', 'name')->withMeta('budget')->where('status', 'active')->getAll();
Campaign::query()->insert(['name' => 'Summer Sale', 'slug' => 'summer-sale']);
Campaign::query()->where('id', 1950)->update(['name' => 'Updated']);
Campaign::query()->where('id', 1950)->delete();

Table of Contents

Base Query

Apply default conditions to every query a model makes. Useful for models that represent a subset of a shared table

class Product extends Model
{
    protected string $table = 'posts';
    protected string $primaryKey = 'ID';

    public int $ID;
    public string $post_title;
    public string $post_status;

    protected static function baseQuery(ModelQueryBuilder $builder): ModelQueryBuilder
    {
        return $builder->where('post_type', 'product');
    }
}

// Every query automatically includes WHERE post_type = 'product'
Product::query()->where('post_status', 'publish')->getAll();
Product::query()->find('ID', 1911);
Product::query()->count('ID');

Scopes

Define reusable query filters as static methods on your model

class Campaign extends Model
{
    protected string $table = 'campaigns';

    public static function active(): ModelQueryBuilder
    {
        return static::query()->where('status', 'active');
    }

    public static function p2p(): ModelQueryBuilder
    {
        return static::query()->where('campaign_type', 'p2p');
    }
}

Campaign::active()->getAll();
Campaign::active()->orderBy('name')->limit(10)->getAll();
Campaign::p2p()->getAll();

Save

Create or update a model instance

// Create new
$campaign = Campaign::make();
$campaign->name = 'Summer';
$campaign->slug = 'summer';
$campaign->price = 29.99;
$campaign->save();

// make() also takes an optional attributes array, applied the same way
// $model['key'] = $value would be: a declared property is set directly,
// anything else lands in the extras bag.
$campaign = Campaign::make(['name' => 'Summer', 'slug' => 'summer']);
$campaign->save();

// Update existing
$campaign = Campaign::query()->find('id', 1950);
$campaign->name = 'Updated';
$campaign->save();

The primary key defaults to id. Override $primaryKey in your model if needed:

class Post extends Model
{
    protected string $table = 'posts';
    protected string $primaryKey = 'ID';
}

Tables whose key is not generated

save() reads the primary key to decide between an insert and an update, so it needs the database to generate that key. On a table with an AUTO_INCREMENT column that is what happens. On any other table the decision is wrong in one direction or the other and wrong silently, so save() refuses those tables and insert() writes them instead:

$value = EntryValue::make(['entry_id' => 9, 'field_key' => 'fld_00000000000a']);
$value->insert();

insert() writes every column the model holds, including the key columns, and never reads the key back. There is no create-or-update helper: making a read and a write atomic has to be stated in SQL (INSERT ... ON DUPLICATE KEY UPDATE), and this layer cannot do it for you.

Lifecycle Hooks

Override onBeforeSave() and onSave() to run logic before and after save()

class Campaign extends Model
{
    protected string $table = 'campaigns';

    protected function onBeforeSave(): void
    {
        if (empty($this->slug)) {
            $this->slug = sanitize_title($this->name);
        }
    }

    protected function onSave(): void
    {
        do_action('campaign_saved', $this);
    }
}

onBeforeSave() runs before the database write. Any property changes made there are included in the save

onSave() runs after the database write

Insert

// Single row
Campaign::query()->insert(['name' => 'Summer', 'slug' => 'summer']);
// Returns: QueryResult { affectedRows: 1, insertId: 1 }

// bulk insert
Campaign::query()->insert([
    ['name' => 'Summer', 'slug' => 'summer'],
    ['name' => 'Winter', 'slug' => 'winter'],
]);

// insert with meta (meta fields are separated automatically)
Campaign::query()->withMeta('budget', 'channel')->insert([
    'name'    => 'Summer',
    'slug'    => 'summer',
    'budget'  => '1950', // campaign_meta table
    'channel' => 'email', // campaign_meta table
]);

// upsert (insert or update on duplicate key)
Campaign::query()->upsert(
    ['name' => 'Summer', 'slug' => 'summer'],
    ['slug'], // conflict columns
    ['name'], // columns to update
);

The INSERT column list is always backtick-quoted, so a data key named after a reserved word (cursor, trigger, ...) still compiles to valid SQL.

Update

Campaign::query()->where('id', 1)->update(['name' => 'Updated']);

// update meta
Campaign::query()->withMeta('budget')->where('id', 1)->update([
    'name'   => 'Updated',
    'budget' => '1911',// updates meta row
]);

// Increment / Decrement
Campaign::query()->where('id', 1)->increment('stock');
Campaign::query()->where('id', 1)->increment('stock', 5);
Campaign::query()->where('id', 1)->decrement('stock', 3);

// raw update
Campaign::query()->where('id', 1)->updateRaw("stock = stock + 1");

The UPDATE SET clause quotes column names the same way the INSERT column list does.

Delete

Campaign::query()->where('id', 1)->delete();
// meta rows are deleted automatically if the model has meta config

// truncate (clears both the table and meta table)
Campaign::query()->truncate();

Select

// all columns
Campaign::query()->getAll();

// select specific columns
Campaign::query()->select('id', 'name', 'slug')->getAll();

// single row (LIMIT 1, returns null if not found)
Campaign::query()->select('id', 'name')->get();

// distinct
Campaign::query()->distinct()->select('status')->getAll();

// select with alias
Campaign::query()->select(['id' => 'campaign_id'])->getAll();

// raaw select
Campaign::query()->selectRaw('COUNT(*) as total')->get();

// Subquery in FROM
DB::table(function ($qb) {
    $qb->table('orders')->select('user_id')->groupBy('user_id');
}, 
'order_totals')->selectRaw('user_id, total')->getAll();

Where Clauses

// basic
Campaign::query()->where('status', 'active')->getAll();
Campaign::query()->where('status', 'active')->orWhere('status', 'pending')->getAll();

// LIKE
Campaign::query()->whereLike('name', 'summer')->getAll(); // LIKE '%summer%'
Campaign::query()->whereLike('name', 'summer%')->getAll(); // LIKE 'summer%'

// IN / NOT IN
Campaign::query()->whereIn('id', [1, 2, 3])->getAll();
Campaign::query()->whereNotIn('status', ['draft', 'trash'])->getAll();

// BETWEEN
Campaign::query()->whereBetween('price', 10, 100)->getAll();

// NULL
Campaign::query()->whereIsNull('deleted_at')->getAll();
Campaign::query()->whereIsNotNull('email')->getAll();

// column comparison (no value escaping), useful for JOINS
Campaign::query()->whereColumn('users.id', 'orders.user_id')->getAll();

// nested groups
Campaign::query()->where('status', 'active')
    ->orWhere(function ($qb) {
        $qb->where('role', 'admin')->where('verified', 1);
    })
    ->getAll();
// WHERE status = 'active' OR (role = 'admin' AND verified = 1)

// Subquery
Campaign::query()->whereIn('id', function ($qb) {
    $qb->table('orders')->select('campaign_id')->where('total', 100, '>');
})->getAll();

// EXISTS
Campaign::query()->whereExists(function ($qb) {
    $qb->table('orders')->select('id')->whereRaw('orders.campaign_id = campaigns.id');
})->getAll();

// Raw
Campaign::query()->whereRaw('created_at > NOW() - INTERVAL 30 DAY')->getAll();

All where methods have or variants: orWhere, orWhereLike, orWhereIn, orWhereBetween, orWhereIsNull, orWhereExists, orWhereColumn.

Joins

Campaign::query()->leftJoin('entries', 'campaigns.id', 'entries.campaign_id')->getAll();
Campaign::query()->innerJoin('entries', 'campaigns.id', 'entries.campaign_id')->getAll();
Campaign::query()->rightJoin('entries', 'campaigns.id', 'entries.campaign_id')->getAll();
Campaign::query()->crossJoin('statuses')->getAll();

// with alias
Campaign::query()->leftJoin('entries', 'campaigns.id', 'e.campaign_id', 'e')->getAll();

// raw join
Campaign::query()->joinRaw('LEFT JOIN entries e ON campaigns.id = e.campaign_id')->getAll();

Ordering, Grouping, Limit & Offset

Campaign::query()->orderBy('name')->getAll();
Campaign::query()->orderBy('name', 'DESC')->getAll();
Campaign::query()->orderByRaw('RAND()')->getAll();

Campaign::query()->groupBy('status')->getAll();
Campaign::query()->groupBy('status', 'channel')->getAll();
Campaign::query()->groupByRaw('YEAR(created_at)')->getAll();

Campaign::query()->limit(10)->offset(20)->getAll();

// order and group by meta keys
Campaign::query()->withMeta('budget')->orderBy('budget', 'DESC')->getAll();
Campaign::query()->withMeta('channel')->groupBy('channel')->getAll();

Having

Campaign::query()->groupBy('status')->havingCount('id', '>', 5)->getAll();
Campaign::query()->groupBy('status')->havingSum('stock', '>', 100)->getAll();
Campaign::query()->groupBy('status')->havingAvg('price', '>', 50)->getAll();
Campaign::query()->groupBy('status')->havingMin('price', '>', 10)->getAll();
Campaign::query()->groupBy('status')->havingMax('price', '<', 1000)->getAll();
Campaign::query()->groupBy('status')->havingRaw('COUNT(id) > 5')->getAll();

// combine with OR
Campaign::query()->groupBy('status')
    ->havingSum('stock', '>', 100)
    ->orHavingAvg('price', '>', 200)
    ->getAll();

Union

$drafts = Campaign::query()->where('status', 'draft')->select('id', 'name');
Campaign::query()->where('status', 'active')->select('id', 'name')->union($drafts)->getAll();

// Union ALL
Campaign::query()->select('id')->unionAll($drafts)->getAll();

Aggregates

Campaign::query()->count('id'); // int
Campaign::query()->sum('price'); // float
Campaign::query()->avg('price'); // float
Campaign::query()->min('price'); // float
Campaign::query()->max('price'); // float

// with conditions
Campaign::query()->where('status', 'active')->count('id');

Find & Exists

// find by column. returns model instance or null
Campaign::query()->find('id', 1950);
Campaign::query()->find('slug', 'summer');

// check if rows exist. returns bool
Campaign::query()->where('slug', 'summer')->exists();

Pluck

Returns a flat array of a single column values:

Campaign::query()->pluck('name');
// ['Save the dolphins', 'All alcoholic beverages 20% off', 'Yeah, no']

Meta Tables

Meta tables follow the WordPress pattern: a separate table with meta_key and meta_value columns linked by a foreign key

Configuration

Define meta config in your models meta() method or in DB::init():

protected function meta(): array
{
    return [
        'table' => 'campaign_meta', // meta table name (auto-prefixed)
        'foreignKey' => 'campaign_id',  // FK in meta table
        'primaryKey' => 'id', // PK in main table
        'aliases' => [  // meta keys aliases
            'budget' => '_campaign_budget',
            'channel' => '_campaign_channel',
            'tags' => [
                'key' => '_campaign_tags', 
                'multiple' => true
            ],
        ],
    ];
}

Aliases

Aliases let you use friendly names instead of ugly meta key names:

// without aliases, you have to write
->withMeta('_campaign_budget')

// with aliases
->withMeta('budget')

Single vs Multiple values

Some meta keys can have multiple rows with the same key. Mark these with 'multiple' => true:

'aliases' => [
    'budget' => '_campaign_budget', // single value
    'tags'   => [
        'key' => '_campaign_tags', 
        'multiple' => true // multiple values
    ], 
],

Single values

$campaign = Campaign::query()->select('id')->withMeta('budget')->get();
$campaign->budget; // 5000

Multiple values are parsed into arrays:

$campaign = Campaign::query()->select('id')->withMeta('tags')->groupBy('id')->get();
$campaign->tags; // ['promo', 'seasonal', 'email']

Meta in Queries

withMeta() makes meta keys available in select, where, orderBy, groupBy

// get specific keys
Campaign::query()->select('id', 'name')
    ->withMeta('budget', 'channel')
    ->where('budget', 1000, '>') // WHERE meta_budget.meta_value > 1000
    ->orderBy('budget', 'DESC') // ORDER BY meta_budget.meta_value DESC
    ->groupBy('channel') // GROUP BY meta_channel.meta_value
    ->getAll();

// get campaign with all meta keys
Campaign::query()->select('id', 'name')->withMeta()->getAll();

Meta in Mutations

// Insert: meta fields are auto-separated from table columns
Campaign::query()->withMeta('budget', 'tags')->insert([
    'name' => 'Summer',
    'slug' => 'summer',
    'budget' => '1911', // 1 row in campaign_meta
    'tags' => ['promo', 'seasonal'], // 2 rows in campaign_meta
]);

// Update: single meta upserted, multiple meta replaced
Campaign::query()->withMeta('budget', 'tags')->where('id', 1)->update([
    'budget' => '1950', // updates existing meta row
    'tags' => ['promo', 'holiday'], // deletes old rows, inserts new
]);

// Delete: meta rows are deleted before campaign
Campaign::query()->withMeta('budget')->where('id', 1)->delete();

Relations

Relations define how tables are connected. The parent table's rows are fetched first, then related rows are loaded in a separate query and attached

Configuration

protected function relations(): array
{
    return [
        'entries' => [
            'table' => 'campaign_entries',
            'foreignKey' => 'campaign_id',
            'primaryKey' => 'id',
            'type' => 'hasMany', // hasMany | hasOne | belongsTo
        ],
    ];
}

Usage

// eager load relations with with()
$campaign = Campaign::query()->select('id', 'name')->with('entries')->get();

// hasMany returns an array
$campaign->entries;
// [
//     ['id' => 1, 'email' => 'a@test.com'],
//     ['id' => 2, 'email' => 'b@test.com'],
// ]

// hasOne returns single row or null
$campaign->profile; // ['id' => 1, 'bio' => '...'] or null

Relations work with both get() and getAll().

Migrations

Each model defines its table structure via schema() and a $version string. Calling migrate() runs dbDelta()

Defining Schema

Schema is defined separately from the model class using a static callback

class Campaign extends Model
{
    protected string $table = 'campaigns';
    protected string $version = '1.0.0';

    public int $id;
    public string $name;
    // ...
}

Campaign::schema(function (Table $table) {
    $table->id();
    $table->string('name');
    $table->string('slug', 100)->unique();
    $table->decimal('price', 8, 2)->default(0);
    $table->integer('stock')->default(0);
    $table->boolean('active')->default(true);
    $table->text('description')->nullable();
    $table->datetime('created_at')->nullable();
});

Running Migrations

When you change the schema, bump the version

protected string $version = '1.1.0'; // added 'priority' column

Call migrate() on plugin activation:

register_activation_hook(__FILE__, function () {
    require_once ABSPATH . 'wp-admin/includes/upgrade.php';

    Campaign::migrate();
    CampaignEntry::migrate();
});

Force migration regardless of version

Campaign::migrate(true);

Available Column Types

Method MySQL Type
$table->id() BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
$table->string('name') VARCHAR(255) NOT NULL
$table->string('code', 50) VARCHAR(50) NOT NULL
$table->char('field_key', 16) CHAR(16) NOT NULL
$table->binary('content_hash', 32) BINARY(32) NOT NULL
$table->text('body') TEXT NOT NULL
$table->longText('content') LONGTEXT NOT NULL
$table->integer('count') INT NOT NULL
$table->bigInteger('views') BIGINT NOT NULL
$table->smallInteger('row_index') SMALLINT NOT NULL
$table->tinyInteger('priority') TINYINT NOT NULL
$table->float('rating') FLOAT NOT NULL
$table->decimal('price', 8, 2) DECIMAL(8,2) NOT NULL
$table->boolean('active') TINYINT(1) NOT NULL
$table->date('birth_date') DATE NOT NULL
$table->datetime('published_at') DATETIME NOT NULL
$table->timestamp('verified_at') TIMESTAMP NOT NULL
$table->json('settings') JSON NOT NULL
$table->enum('status', ['a', 'b']) ENUM('a','b') NOT NULL

JSON Columns

Models with array properties on json() columns roundtrip transparently. Arrays are JSON-encoded on save and decoded on read.

class Donation extends Model
{
    protected string $table = 'donations';

    public int $id;
    public ?array $gateway_metadata = null;
    public ?array $source_attribution = null;
}

Donation::schema(function (Table $t) {
    $t->id();
    $t->json('gateway_metadata')->nullable();
    $t->json('source_attribution')->nullable();
});

$d = Donation::make();
$d->gateway_metadata = ['stripe' => 'pi_123', 'fee' => 25];
$d->save();

$reloaded = Donation::query()->find('id', $d->id);
$reloaded->gateway_metadata; // ['stripe' => 'pi_123', 'fee' => 25]

Notes:

  • null stays null in both directions.
  • If the property type is ?string, the user supplied string is written verbatim.
  • Encoding uses JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES so multibyte characters and URLs stay readable in the DB.
  • Column metadata is derived once per class per request from the registered schema() callback and cached.

Column Modifiers

$table->string('email')->unique();
$table->integer('stock')->default(0)->unsigned();
$table->datetime('deleted_at')->nullable();
$table->boolean('featured')->default(false);
$table->bigInteger('user_id')->unsigned()->references('users', 'ID')->onDelete('CASCADE');
$table->string('country', 2)->index();

Available modifiers: ->nullable(), ->unique(), ->primary(), ->unsigned(), ->charset($charset, $collate = null), ->default($value), ->references($table, $column), ->onDelete($action), ->index($name = null)

->charset() emits CHARACTER SET x COLLATE y between the type and the NULL constraint, which is the only position MySQL accepts. Omit the second argument to take the character set's default collation.

$table->char('field_key', 16)->charset('ascii', 'ascii_bin');
$table->string('projection_state', 16)->charset('ascii');

Table Options

A composite primary key is declared on the table. It replaces every column-level ->primary(), including the implicit one id() sets, because a table may carry only one and dbDelta cannot drop one afterwards. Unknown column names throw InvalidArgumentException.

$table->primary(['entry_id', 'field_key', 'row_index', 'value_index']);
// PRIMARY KEY (`entry_id`, `field_key`, `row_index`, `value_index`)

rowFormat() and collation() set the trailing table options. ROW_FORMAT=DYNAMIC is what raises the InnoDB index key limit from 767 to 3072 bytes.

$table->rowFormat('DYNAMIC');
$table->collation('utf8mb4', 'utf8mb4_unicode_520_ci');
// ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci ROW_FORMAT=DYNAMIC

A model declares the collation its tables are created with, rather than inheriting $wpdb:

class EntryValue extends Model
{
    protected array $collation = ['utf8mb4', 'utf8mb4_unicode_520_ci'];
}

Leave the property empty to fall back to whatever $wpdb reports.

Indexes

Single-column indexes can be declared fluently on the column:

$table->string('country', 2)->index();              // KEY idx_country (`country`)
$table->string('country', 2)->index('cc_idx');      // KEY cc_idx (`country`)

Composite indexes are declared on the table:

$table->index(['status', 'paid_at']);                   // KEY idx_status_paid_at (`status`,`paid_at`)
$table->index(['status', 'paid_at'], 'idx_pay_status'); // KEY idx_pay_status (`status`,`paid_at`)

Composite unique indexes use Table::unique()

$table->unique(['gateway', 'external_id']);             // UNIQUE KEY uk_gateway_external_id (`gateway`,`external_id`)
$table->unique(['gateway', 'external_id'], 'uk_gw_ext'); // UNIQUE KEY uk_gw_ext (`gateway`,`external_id`)

When no name is supplied, names are generated as idx_ / uk_ + the column list joined with underscores, lowercased. Names longer than 64 characters (MySQL's identifier limit) are truncated with a 6-char hash suffix to stay collision-safe.

Output is dbDelta()-compatible: uppercase KEY on its own line with tight (col1,col2) formatting. Column names in compile()'s output (column definitions, PRIMARY KEY/UNIQUE KEY/KEY/FOREIGN KEY column lists) are always backtick-quoted, so a column named after a MySQL reserved word (trigger, cursor, ...) still compiles to valid DDL.

Meta Table Auto-Creation

If the model has a meta() config, migrate() automatically creates the meta table

protected function meta(): array
{
    return [
        'table' => 'campaign_meta',
        'foreignKey' => 'campaign_id',
        'primaryKey' => 'id',
    ];
}

// Campaign::migrate() creates both tables

Transactions

// using a model
Campaign::transaction(function () {
    Campaign::query()->insert(['name' => 'A', 'slug' => 'a']);
    Campaign::query()->insert(['name' => 'B', 'slug' => 'b']);
});

// using DB facade
DB::transaction(function () {
    DB::table('campaigns')->insert(['name' => 'A', 'slug' => 'a']);
    DB::table('campaigns')->insert(['name' => 'B', 'slug' => 'b']);
});

// auto-commits on success, rollbacks if an exception is thrown

Raw Queries

DB::raw('SELECT * FROM wp_posts WHERE post_status = %s', ['publish']);

Parameters use $wpdb->prepare() for escaping

Conditional Clauses

Conditionally add clauses

$status = $_GET['status'] ?? null;

Campaign::query()->select('id', 'name')
    ->when($status, fn ($qb) => $qb->where('status', $status))
    ->getAll();

Clone

Create an independent copy to build query variants

$base = Campaign::query()->select('id', 'name');
$active = $base->clone()->where('status', 'active');
$drafts = $base->clone()->where('status', 'draft');

SQL Output

Get the generated SQL without executing:

Campaign::query()->select('id', 'name')->where('status', 'active')->toSQL();
// SELECT id, name FROM wp_campaigns WHERE status = 'active'

About

A fluent SQL query builder for WordPress

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages