定制 flowpack/query-object-builder 二次开发

按需修改功能、优化性能、对接业务系统,提供一站式技术支持

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

flowpack/query-object-builder

Composer 安装命令:

composer require flowpack/query-object-builder

包简介

A fluent, immutable, fully-typed SQL query builder for PHP (PostgreSQL, with more dialects planned).

README 文档

README

Latest Stable Version PHP Version Require CI License

A fluent, immutable, fully-typed SQL query builder for PHP 8.4+ with extensive support for PostgreSQL-specific features.

You compose a query from small, type-safe expression objects and render it to a parameterized SQL string with bound arguments — never by concatenating strings.

Why Query Object Builder?

  • Pure PostgreSQL focus — no compromises for lowest-common-denominator multi-database support.
  • JSON-first design — first-class support for json_build_object and json_agg to build hierarchical data directly in the database.
  • Complete feature set — CTEs, window functions, arrays, grouping sets, ON CONFLICT, RETURNING, set-returning functions, and more.
  • Type-safe — builder methods only expose what is valid in the current context, so invalid queries are hard to express.
  • Immutable — every builder method returns a new instance, so base queries can be shared and specialised without surprises.

Requirements

  • PHP 8.4 or newer

Installation

composer require flowpack/query-object-builder

Quick start

use Flowpack\QueryObjectBuilder\PostgreSQL\Q;

$active = true;

$q = Q::select(Q::n('name'), Q::n('email'))
    ->from(Q::n('users'))
    ->where(Q::n('active')->eq(Q::arg($active)))
    ->orderBy(Q::n('name'));

[$sql, $args] = Q::build($q)->toSql();

echo $sql;             // SELECT name,email FROM users WHERE active = $1 ORDER BY name
var_dump($args);       // [true]

Q::build($q)->toSql() returns a [$sql, $args] pair: a SQL string with PostgreSQL numbered placeholders ($1, $2, …) and the positional argument list to bind. See Executing queries for how to run it.

Core concepts

The Q facade

Q is the single entry point for building queries. It exposes the builder package as a small set of static factory methods so you never reference the underlying builder types directly:

  • Statements: Q::select(), Q::insertInto(), Q::update(), Q::deleteFrom(), Q::with(), Q::withRecursive()
  • Identifiers: Q::n('table.column') for names/columns
  • Literals: Q::string(), Q::int(), Q::float(), Q::bool(), Q::null(), Q::default(), Q::array(), Q::interval()
  • Parameters: Q::arg() (positional), Q::bind() (named)
  • Composition: Q::and(), Q::or(), Q::not(), Q::exists(), Q::case(), Q::coalesce(), Q::func(), Q::agg()

The Q\Func facade

SQL functions live on the Q\Func facade: Q\Func::jsonBuildObject(), Q\Func::jsonAgg(), Q\Func::count(), Q\Func::sum(), Q\Func::upper(), Q\Func::rowNumber(), Q\Func::unnest(), and many more. It is named Func (not Fn) because fn is a reserved keyword in PHP.

Immutability

Every builder method returns a new builder — the original is never mutated. This makes base queries safe to reuse:

$base = Q::select(Q::n('*'))->from(Q::n('users'));

$active = $base->where(Q::n('active')->eq(Q::bool(true)));
$recent = $base->where(Q::n('created_at')->gt(Q::string('2024-01-01')));
// $base is unchanged

Operators on expressions

Expressions returned by Q::n(), Q::arg(), literals, and functions carry the SQL operators as fluent methods: ->eq(), ->neq(), ->lt(), ->gt(), ->like(), ->ilike(), ->in(), ->isNull(), ->isNotNull(), ->plus(), ->minus(), ->mult(), ->concat(), ->cast(), ->op('*', …), and more. Parentheses are added automatically based on operator precedence.

Examples

The builder emits compact, single-line SQL. The SQL shown below is formatted for readability — it is otherwise exactly what each query renders.

Basic queries

Simple SELECT

$q = Q::select(Q::n('*'))->from(Q::n('users'));
SELECT * FROM users

SELECT with WHERE

$q = Q::select(Q::n('name'), Q::n('email'))
    ->from(Q::n('users'))
    ->where(Q::n('active')->eq(Q::bool(true)));
SELECT name, email FROM users WHERE active = true

SELECT with multiple conditions

$q = Q::select(Q::n('*'))
    ->from(Q::n('employees'))
    ->where(Q::and(
        Q::or(
            Q::n('firstname')->ilike(Q::arg('John%')),
            Q::n('lastname')->ilike(Q::arg('John%')),
        ),
        Q::n('active')->eq(Q::bool(true)),
    ));
SELECT * FROM employees
WHERE (firstname ILIKE $1 OR lastname ILIKE $2) AND active = true

SELECT DISTINCT

$q = Q::select()->distinct()
    ->select(Q::n('department'))
    ->from(Q::n('employees'));
SELECT DISTINCT department FROM employees

SELECT with ORDER BY, LIMIT and OFFSET

$q = Q::select(Q::n('name'), Q::n('salary'))
    ->from(Q::n('employees'))
    ->orderBy(Q::n('salary'))->desc()->nullsLast()
    ->limit(Q::int(10))
    ->offset(Q::int(20));
SELECT name, salary FROM employees
ORDER BY salary DESC NULLS LAST
LIMIT 10 OFFSET 20

CRUD operations

INSERT with VALUES

$q = Q::insertInto(Q::n('users'))
    ->columnNames('name', 'email', 'active')
    ->values(Q::string('John Doe'), Q::string('john@example.com'), Q::bool(true));
INSERT INTO users (name, email, active)
VALUES ('John Doe', 'john@example.com', true)

INSERT multiple rows

$q = Q::insertInto(Q::n('products'))
    ->columnNames('name', 'price', 'category')
    ->values(Q::string('Laptop'), Q::float(999.99), Q::string('Electronics'))
    ->values(Q::string('Book'), Q::float(19.99), Q::string('Literature'));
INSERT INTO products (name, price, category) VALUES
    ('Laptop', 999.99, 'Electronics'),
    ('Book', 19.99, 'Literature')

INSERT from a map of values

$q = Q::insertInto(Q::n('films'))
    ->setMap([
        'code' => 'UA502',
        'title' => 'Bananas',
        'did' => 105,
    ]);
INSERT INTO films (code,did,title) VALUES ($1, $2, $3)
-- args: ['UA502', 105, 'Bananas']

INSERT from a SELECT

$q = Q::insertInto(Q::n('archived_users'))
    ->query(Q::select(Q::n('*'))->from(Q::n('users'))->where(Q::n('active')->eq(Q::bool(false))));
INSERT INTO archived_users SELECT * FROM users WHERE active = false

INSERT with RETURNING

$q = Q::insertInto(Q::n('users'))
    ->columnNames('name', 'email')
    ->values(Q::string('Jane Doe'), Q::string('jane@example.com'))
    ->returning(Q::n('id'), Q::n('created_at'));
INSERT INTO users (name, email) VALUES ('Jane Doe', 'jane@example.com')
RETURNING id, created_at

UPSERT (INSERT … ON CONFLICT)

$q = Q::insertInto(Q::n('distributors'))
    ->columnNames('did', 'dname')
    ->values(Q::int(5), Q::string('Gizmo Transglobal'))
    ->onConflict(Q::n('did'))->doUpdate()
    ->set('dname', Q::n('EXCLUDED.dname'));
INSERT INTO distributors (did, dname) VALUES (5, 'Gizmo Transglobal')
ON CONFLICT (did) DO UPDATE SET dname = EXCLUDED.dname

UPDATE

$q = Q::update(Q::n('films'))
    ->set('kind', Q::string('Dramatic'))
    ->where(Q::n('kind')->eq(Q::string('Drama')));
UPDATE films SET kind = 'Dramatic' WHERE kind = 'Drama'

UPDATE with FROM

$q = Q::update(Q::n('employees'))->as('e')
    ->set('department_name', Q::n('d.name'))
    ->from(Q::n('departments'))->as('d')
    ->where(Q::n('e.department_id')->eq(Q::n('d.id')));
UPDATE employees AS e SET department_name = d.name
FROM departments AS d
WHERE e.department_id = d.id

DELETE

$q = Q::deleteFrom(Q::n('films'))
    ->where(Q::n('kind')->neq(Q::string('Musical')));
DELETE FROM films WHERE kind <> 'Musical'

DELETE with USING

$q = Q::deleteFrom(Q::n('films'))
    ->using(Q::n('producers'))
    ->where(Q::and(
        Q::n('producer_id')->eq(Q::n('producers.id')),
        Q::n('producers.name')->eq(Q::string('foo')),
    ));
DELETE FROM films USING producers
WHERE producer_id = producers.id AND producers.name = 'foo'

Joins

INNER JOIN

$q = Q::select(Q::n('u.name'), Q::n('p.title'))
    ->from(Q::n('users'))->as('u')
    ->join(Q::n('posts'))->as('p')->on(Q::n('u.id')->eq(Q::n('p.user_id')));
SELECT u.name, p.title FROM users AS u
JOIN posts AS p ON u.id = p.user_id

LEFT JOIN

$q = Q::select(Q::n('u.name'), Q::n('p.title'))
    ->from(Q::n('users'))->as('u')
    ->leftJoin(Q::n('posts'))->as('p')->on(Q::n('u.id')->eq(Q::n('p.user_id')));
SELECT u.name, p.title FROM users AS u
LEFT JOIN posts AS p ON u.id = p.user_id

JOIN with USING

$q = Q::select(Q::n('u.name'), Q::n('p.title'))
    ->from(Q::n('users'))->as('u')
    ->join(Q::n('posts'))->as('p')->using('user_id');
SELECT u.name, p.title FROM users AS u
JOIN posts AS p USING (user_id)

Aggregation & grouping

GROUP BY with an aggregate

$q = Q::select(Q::n('department'))
    ->select(Q\Func::count(Q::n('*')))->as('employee_count')
    ->from(Q::n('employees'))
    ->groupBy(Q::n('department'));
SELECT department, count(*) AS employee_count
FROM employees
GROUP BY department

GROUP BY with HAVING

$q = Q::select(Q::n('department'))
    ->select(Q\Func::avg(Q::n('salary')))->as('avg_salary')
    ->from(Q::n('employees'))
    ->groupBy(Q::n('department'))
    ->having(Q\Func::avg(Q::n('salary'))->gt(Q::int(50000)));
SELECT department, avg(salary) AS avg_salary
FROM employees
GROUP BY department
HAVING avg(salary) > 50000

GROUP BY ROLLUP

$q = Q::select(Q::n('department'), Q::n('job_title'), Q\Func::sum(Q::n('salary')))
    ->from(Q::n('employees'))
    ->groupBy()
    ->rollup(
        Q::exps(Q::n('department')),
        Q::exps(Q::n('job_title')),
    );
SELECT department, job_title, sum(salary)
FROM employees
GROUP BY ROLLUP (department, job_title)

GROUP BY GROUPING SETS

$q = Q::select(Q::n('department'), Q::n('job_title'), Q\Func::sum(Q::n('salary')))
    ->from(Q::n('employees'))
    ->groupBy()
    ->groupingSets(
        Q::exps(Q::n('department')),
        Q::exps(Q::n('job_title')),
        Q::exps(),
    );
SELECT department, job_title, sum(salary)
FROM employees
GROUP BY GROUPING SETS (department, job_title, ())

Window functions

ROW_NUMBER over a partition

$q = Q::select(
    Q::n('name'),
    Q::n('salary'),
    Q\Func::rowNumber()->over()->partitionBy(Q::n('department'))->orderBy(Q::n('salary'))->desc(),
)->from(Q::n('employees'));
SELECT name, salary,
       row_number() OVER (PARTITION BY department ORDER BY salary DESC)
FROM employees

Named windows

$q = Q::select(
    Q\Func::sum(Q::n('salary'))->over('w'),
    Q\Func::avg(Q::n('salary'))->over('w'),
)
    ->from(Q::n('empsalary'))
    ->window('w')->as()->partitionBy(Q::n('depname'))->orderBy(Q::n('salary'))->desc();
SELECT sum(salary) OVER w, avg(salary) OVER w
FROM empsalary
WINDOW w AS (PARTITION BY depname ORDER BY salary DESC)

JSON operations

Build a JSON object

$q = Q::select(
    Q\Func::jsonBuildObject()
        ->prop('id', Q::n('id'))
        ->prop('name', Q::n('name'))
        ->prop('email', Q::n('email')),
)->from(Q::n('users'));
SELECT json_build_object('id', id, 'name', name, 'email', email)
FROM users

JSON aggregation

$q = Q::select(
    Q::n('department'),
    Q\Func::jsonAgg(
        Q\Func::jsonBuildObject()
            ->prop('name', Q::n('name'))
            ->prop('salary', Q::n('salary')),
    )->orderBy(Q::n('name')),
)
    ->from(Q::n('employees'))
    ->groupBy(Q::n('department'));
SELECT department,
       json_agg(json_build_object('name', name, 'salary', salary) ORDER BY name)
FROM employees
GROUP BY department

selectJson for a JSON-first query

When the query's primary output is a single JSON object, Q::selectJson() makes it the first selection and lets you refine it later with applySelectJson():

$q = Q::selectJson(
    Q\Func::jsonBuildObject()
        ->prop('Title', Q::n('books.title'))
        ->prop('ID', Q::n('books.book_id')),
)
    ->from(Q::n('books'))
    ->where(Q::n('books.book_id')->eq(Q::arg(2)));
SELECT json_build_object('Title', books.title, 'ID', books.book_id)
FROM books
WHERE books.book_id = $1

Array operations

Array construction

$q = Q::select(Q::array(Q::string('a'), Q::string('b'), Q::string('c')));
SELECT ARRAY['a','b','c']

Array functions

$q = Q::select(
    Q\Func::arrayAppend(Q::array(Q::int(1), Q::int(2)), Q::int(3)),
    Q\Func::arrayLength(Q::array(Q::int(1), Q::int(2), Q::int(3)), Q::int(1)),
);
SELECT array_append(ARRAY[1,2], 3), array_length(ARRAY[1,2,3], 1)

UNNEST

$q = Q::select(Q::n('*'))
    ->from(Q\Func::unnest(Q::array(Q::string('a'), Q::string('b'), Q::string('c'))))
    ->as('t')->columnAliases('value');
SELECT * FROM unnest(ARRAY['a','b','c']) AS t (value)

Array aggregation

$q = Q::select(
    Q::n('department'),
    Q\Func::arrayAgg(Q::n('name'))->orderBy(Q::n('name')),
)
    ->from(Q::n('employees'))
    ->groupBy(Q::n('department'));
SELECT department, array_agg(name ORDER BY name)
FROM employees
GROUP BY department

Subqueries

EXISTS

$q = Q::select(Q::n('name'))
    ->from(Q::n('users'))
    ->where(Q::exists(
        Q::select(Q::int(1))
            ->from(Q::n('posts'))
            ->where(Q::n('posts.user_id')->eq(Q::n('users.id'))),
    ));
SELECT name FROM users
WHERE EXISTS (SELECT 1 FROM posts WHERE posts.user_id = users.id)

IN with a subquery

$q = Q::select(Q::n('name'))
    ->from(Q::n('users'))
    ->where(Q::n('id')->in(
        Q::select(Q::n('user_id'))
            ->from(Q::n('posts'))
            ->where(Q::n('published')->eq(Q::bool(true))),
    ));
SELECT name FROM users
WHERE id IN (SELECT user_id FROM posts WHERE published = true)

IN with bound arguments

$ids = [1, 2, 3];

$q = Q::select(Q::n('username'))
    ->from(Q::n('accounts'))
    ->where(Q::n('id')->in(Q::args(...$ids)));
SELECT username FROM accounts WHERE id IN ($1, $2, $3)
-- args: [1, 2, 3]

Correlated subquery

$q = Q::select(Q::n('name'), Q::n('salary'))
    ->from(Q::n('employees'))->as('e1')
    ->where(Q::n('salary')->gt(
        Q::select(Q\Func::avg(Q::n('salary')))
            ->from(Q::n('employees'))->as('e2')
            ->where(Q::n('e1.department')->eq(Q::n('e2.department'))),
    ));
SELECT name, salary FROM employees AS e1
WHERE salary > (
    SELECT avg(salary) FROM employees AS e2
    WHERE e1.department = e2.department
)

Subquery in FROM

$q = Q::select(Q::n('avg_quantity'))
    ->from(
        Q::select(Q\Func::avg(Q::n('quantity')))->as('avg_quantity')
            ->from(Q::n('sales'))
            ->groupBy(Q::n('brand')),
    )->as('t');
SELECT avg_quantity FROM (
    SELECT avg(quantity) AS avg_quantity FROM sales GROUP BY brand
) AS t

Common Table Expressions (WITH)

Simple CTE

$q = Q::with('recent_orders')->as(
    Q::select(Q::n('*'))
        ->from(Q::n('orders'))
        ->where(Q::n('created_at')->gt(Q::string('2023-01-01'))),
)
    ->select(Q::n('customer_name'), Q\Func::count(Q::n('*')))
    ->from(Q::n('recent_orders'))
    ->groupBy(Q::n('customer_name'));
WITH recent_orders AS (
    SELECT * FROM orders WHERE created_at > '2023-01-01'
)
SELECT customer_name, count(*) FROM recent_orders GROUP BY customer_name

Recursive CTE

$q = Q::withRecursive('employee_recursive')
    ->columnNames('distance', 'employee_name', 'manager_name')->as(
        Q::select(Q::int(1), Q::n('employee_name'), Q::n('manager_name'))
            ->from(Q::n('employee'))
            ->where(Q::n('manager_name')->eq(Q::string('Mary')))
            ->union()->all()
            ->select(Q::n('er.distance')->op('+', Q::int(1)), Q::n('e.employee_name'), Q::n('e.manager_name'))
            ->from(Q::n('employee_recursive'))->as('er')
            ->from(Q::n('employee'))->as('e')
            ->where(Q::n('er.employee_name')->eq(Q::n('e.manager_name'))),
    )
    ->select(Q::n('distance'), Q::n('employee_name'))->from(Q::n('employee_recursive'));
WITH RECURSIVE employee_recursive(distance, employee_name, manager_name) AS (
    SELECT 1, employee_name, manager_name
    FROM employee
    WHERE manager_name = 'Mary'
  UNION ALL
    SELECT er.distance + 1, e.employee_name, e.manager_name
    FROM employee_recursive AS er, employee AS e
    WHERE er.employee_name = e.manager_name
)
SELECT distance, employee_name FROM employee_recursive

Functions & operators

String functions

$q = Q::select(
    Q\Func::upper(Q::n('name')),
    Q\Func::lower(Q::n('email')),
    Q\Func::initcap(Q::n('title')),
)->from(Q::n('users'));
SELECT upper(name), lower(email), initcap(title)
FROM users

Date/time functions

$q = Q::select(
    Q\Func::extract('year', Q::n('created_at')),
    Q::n('created_at')->plus(Q::interval('1 day')),
)->from(Q::n('orders'));
SELECT EXTRACT(year FROM created_at), created_at + INTERVAL '1 day'
FROM orders

Mathematical operators

$q = Q::select(Q::n('price')->op('*', Q::n('quantity')))->as('total')
    ->from(Q::n('order_items'));
SELECT price * quantity AS total FROM order_items

CASE expressions

$q = Q::select(
    Q::n('name'),
    Q::case()
        ->when(Q::n('salary')->lt(Q::int(30000)))->then(Q::string('Low'))
        ->when(Q::n('salary')->lt(Q::int(70000)))->then(Q::string('Medium'))
        ->else(Q::string('High'))
        ->end(),
)->from(Q::n('employees'));
SELECT name,
       CASE
           WHEN salary < 30000 THEN 'Low'
           WHEN salary < 70000 THEN 'Medium'
           ELSE 'High'
       END
FROM employees

Casts

$q = Q::select(Q::n('articles.content')->cast('text'))
    ->from(Q::n('articles'))
    ->where(Q::n('articles.content')->cast('text')->ilike(Q::arg('%foo%')));
SELECT articles.content::text FROM articles WHERE articles.content::text ILIKE $1
-- args: ['%foo%']

Parameters

Positional parameters

Each Q::arg() becomes a numbered placeholder in order of appearance:

$q = Q::select(Q::n('*'))
    ->from(Q::n('users'))
    ->where(Q::and(
        Q::n('name')->like(Q::arg('John%')),
        Q::n('active')->eq(Q::arg(true)),
    ));

[$sql, $args] = Q::build($q)->toSql();
SELECT * FROM users WHERE name LIKE $1 AND active = $2
-- args: ['John%', true]

Named parameters

Q::bind() declares a named placeholder; bind the values with withNamedArgs(). Reusing the same name reuses its placeholder:

$q = Q::select(Q::n('*'))
    ->from(Q::n('users'))
    ->where(Q::and(
        Q::n('name')->like(Q::bind('search')),
        Q::n('active')->eq(Q::bind('is_active')),
    ));

[$sql, $args] = Q::build($q)
    ->withNamedArgs(['search' => 'John%', 'is_active' => true])
    ->toSql();
SELECT * FROM users WHERE name LIKE $1 AND active = $2
-- args: ['John%', true]

Named and positional parameters can be mixed in the same query.

Executing queries

The builder is driver-agnostic: it produces a SQL string with PostgreSQL numbered placeholders ($1, $2, …) and a positional argument list. Feed both to any layer that speaks PostgreSQL's native placeholders, for example the pgsql extension:

use Flowpack\QueryObjectBuilder\PostgreSQL\Q;

$conn = pg_connect('host=localhost dbname=app user=app');

$q = Q::select(Q::n('name'), Q::n('email'))
    ->from(Q::n('users'))
    ->where(Q::n('active')->eq(Q::arg(true)));

[$sql, $args] = Q::build($q)->toSql();

$result = pg_query_params($conn, $sql, $args);
while ($row = pg_fetch_assoc($result)) {
    printf("Name: %s, Email: %s\n", $row['name'], $row['email']);
}

By default, identifiers are validated while building and an invalid name throws a QueryBuilderException. Skip validation with Q::build($q)->withoutValidation()->toSql() when you trust the input.

Best practices

Reuse expressions and base queries

$userName  = Q::n('users.name');
$userEmail = Q::n('users.email');

$q = Q::select($userName, $userEmail)->from(Q::n('users'));

Build queries conditionally with applyIf

Builders expose applyIf() so optional clauses read top-to-bottom without breaking the fluent chain:

$q = Q::update(Q::n('films'))
    ->set('kind', Q::string('Dramatic'))
    ->where(Q::n('kind')->eq(Q::string('Drama')))
    ->applyIf($onlyActive, fn ($q) => $q->where(Q::n('archived')->eq(Q::bool(false))));

Organise complex reports with CTEs

Break a large query into named, readable parts with Q::with() and chain several CTEs with appendWith().

Development

composer install

composer test       # run the Pest test suite
composer analyse    # run PHPStan (level max)

Both must pass for any change.

License

Licensed under the GNU General Public License v3.0 or later.

统计信息

  • 总下载量: 0
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 0
  • 点击次数: 0
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: GPL-3.0-or-later
  • 更新时间: 2026-06-29

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固