Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Changelog

All notable changes to `jardissupport/dbquery` are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
versioning follows [SemVer](https://semver.org/).

## [Unreleased]

### Added

- **Identifier auto-quoting for simple identifiers.** Fields passed to
`where()`/`and()`/`or()`, `having()`, `orderBy()`, `groupBy()` and the
`select()` field list are now quoted with the dialect's identifier quoting
(MySQL/MariaDB/SQLite: backtick, PostgreSQL: double quote) when they are
SIMPLE identifiers — `ident` or `alias.ident`, boundary regex
`^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$`. Everything else
(functions, operators, `*`, already quoted strings, `Expression::raw()`)
is emitted byte-identical raw. `Expression::raw()` is the explicit escape
hatch and is never quoted, even for a bare identifier.
SQL literals and niladic functions matching the pattern are excluded
case-insensitively (`SimpleIdentifierQuoter::KEYWORD_EXCEPTIONS`): NULL,
TRUE, FALSE, DEFAULT, CURRENT_TIMESTAMP, CURRENT_DATE, CURRENT_TIME,
LOCALTIME, LOCALTIMESTAMP, CURRENT_USER, SESSION_USER — UNION padding
(`select('id, NULL AS email')`) keeps `NULL` raw; a column literally named
`null` must be passed pre-quoted or via `Expression::raw()`.
New internals: `Query\Formatter\SimpleIdentifierQuoter`,
`Query\Formatter\IdentifierMarkerReplacer`,
`Query\Formatter\FieldListIdentifierQuoter`.

### Fixed

- camelCase columns created with quoted DDL (e.g. `"createdAt"`) failed on
PostgreSQL with error 42703 when referenced in WHERE/ORDER BY/GROUP BY/
SELECT, while the same column arrived correctly quoted in INSERT/UPDATE SET.
The INSERT → WHERE roundtrip on quoted camelCase DDL now works on all
supported dialects (covered by integration tests on MySQL, PostgreSQL and
SQLite).

### Changed

- **Signatures of internal clause builders extended** (technically public
classes, resolved via `BuilderRegistry`): the `__invoke()` methods of
`Query\Builder\Clause\ConditionBuilder` (new `callable
$quoteMarkedIdentifiers` parameter), `SelectBuilder` (new `callable
$quoteFieldList`), `OrderByBuilder` and `GroupByBuilder` (new `callable
$quoteSimpleIdentifier`) take additional quoting callbacks. Custom code
or version overrides calling these builders directly must pass the new
parameters. Known accepted duplication: the quoting helper methods exist
once per SQL builder base class (`Query\SqlBuilder`,
`Command\Update\UpdateSqlBuilder`, `Command\Delete\DeleteSqlBuilder`),
following the existing structure of the JSON/subquery placeholder helpers.
- **Emitted SQL now contains quoted identifiers** in the positions listed
above (semantics unchanged for lowercase/snake_case identifiers on all
supported dialects). Code that string-compares generated SQL must be
adjusted. On PostgreSQL the written identifier is now case-significant:
it must match quoted DDL exactly, or be all-lowercase for unquoted DDL.
Declare aliases with an explicit `AS` so alias definition and references
stay consistent.
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ A fluent SQL query builder for PHP that generates dialect-aware SQL for MySQL, M
## Features

- **Dialect-Aware SQL** — generates correct syntax for MySQL, MariaDB, PostgreSQL, and SQLite from a single builder
- **Identifier Auto-Quoting** — simple identifiers (`ident`, `alias.ident`) in WHERE/HAVING/ORDER BY/GROUP BY/SELECT are quoted per dialect; expressions stay raw (see [Identifier Auto-Quoting](#identifier-auto-quoting))
- **CTEs** — `with()` and `withRecursive()` for common table expressions
- **Window Functions** — `selectWindow()`, `window()`, and `selectWindowRef()` for analytics queries
- **Subqueries** — subqueries in FROM, JOIN constraints, SELECT columns, and WHERE EXISTS / NOT EXISTS
Expand Down Expand Up @@ -48,10 +49,52 @@ $query = (new DbQuery())

// Generate prepared SQL for MySQL
$prepared = $query->sql('mysql', prepared: true);
// $prepared->sql() → "SELECT id, name, email FROM users WHERE status = ? AND created_at >= ? ORDER BY name ASC LIMIT 50"
// $prepared->sql() → "SELECT `id`, `name`, `email` FROM `users` WHERE `status` = ? AND `created_at` >= ? ORDER BY `name` ASC LIMIT 50"
// $prepared->bindings() → ['active', '2024-01-01']
```

## Identifier Auto-Quoting

Simple identifiers are quoted automatically with the dialect's identifier quoting —
MySQL/MariaDB/SQLite use backticks, PostgreSQL uses double quotes. This makes
case-sensitive column names (e.g. `createdAt` from quoted DDL) work on PostgreSQL,
which folds unquoted identifiers to lowercase (error 42703 before).

**What is quoted** — a string is a *simple identifier* iff it matches
`^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$` (`ident` or `alias.ident`).
Simple identifiers are quoted in these positions:

- `where()` / `and()` / `or()` condition fields
- `having()` fields
- `orderBy()` fields
- `groupBy()` columns
- the `select()` field list (per comma-separated item; for `expr AS alias` the
`expr` and the `alias` are each quoted when they are simple identifiers)

**What stays raw (byte-identical)** — everything that is not a simple identifier:

- SQL literals and niladic functions that would otherwise match the pattern
(case-insensitive): `NULL`, `TRUE`, `FALSE`, `DEFAULT`, `CURRENT_TIMESTAMP`,
`CURRENT_DATE`, `CURRENT_TIME`, `LOCALTIME`, `LOCALTIMESTAMP`,
`CURRENT_USER`, `SESSION_USER` — so UNION padding like
`select('id, NULL AS email')` keeps `NULL` raw. A column literally named
`null` must be passed pre-quoted (`` `null` ``/`"null"`) or via
`Expression::raw('"null"')`; qualified names (`t.null`) are always
treated as identifiers.
- expressions and functions (`YEAR(created)`, `price * 1.19`, `COUNT(*)`)
- `*` and `alias.*`
- already quoted strings (`` `createdAt` ``, `"createdAt"`)
- `Expression::raw(...)` — the explicit escape hatch: even a simple identifier
inside `Expression::raw()` is never quoted
- JOIN ON constraints, window specifications (`partitionBy()`, `windowOrderBy()`)
and CTE inner SQL other than what the inner builder itself quotes

**Boundary** — quoting makes the written identifier case-significant on PostgreSQL.
The name you pass must match the DDL exactly when the DDL was quoted, or be
all-lowercase when the DDL was unquoted. Declare aliases with an explicit `AS`
(`COUNT(*) AS orderCount`) so alias definition and alias references are quoted
consistently; use `Expression::raw()` where the raw string is required.

## Advanced Usage

```php
Expand Down
38 changes: 37 additions & 1 deletion src/Command/Delete/DeleteSqlBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
use JardisSupport\DbQuery\Query\Builder\Clause\LimitBuilder;
use JardisSupport\DbQuery\Query\Builder\Clause\OrderByBuilder;
use JardisSupport\DbQuery\Factory\BuilderRegistry;
use JardisSupport\DbQuery\Query\Formatter\IdentifierMarkerReplacer;
use JardisSupport\DbQuery\Query\Formatter\PlaceholderReplacer;
use JardisSupport\DbQuery\Query\Formatter\SimpleIdentifierQuoter;
use JardisSupport\DbQuery\Query\Formatter\ValueFormatter;
use JardisSupport\DbQuery\Query\Processor\JsonPlaceholderProcessor;
use JardisSupport\DbQuery\Query\Validator\SqlInjectionValidator;
Expand Down Expand Up @@ -143,6 +145,7 @@ protected function buildWhere(bool $prepared): string
$prepared,
fn(string $cond) => $this->processJsonPlaceholders($cond),
fn(string $cond) => $this->replaceSubqueryPlaceholders($cond),
fn(string $cond) => $this->quoteMarkedIdentifiers($cond),
$this->bindings
);
}
Expand All @@ -161,7 +164,40 @@ protected function buildOrderBy(): string
/** @var OrderByBuilder $builder */
$builder = $this->registry->get(OrderByBuilder::class);

return $builder($this->state);
return $builder(
$this->state,
fn(string $id) => $this->quoteIfSimpleIdentifier($id)
);
}

/**
* Quote a candidate string when it is a simple identifier
* (`ident` or `alias.ident`), otherwise return it unchanged
*
* @param string $identifier The candidate string
* @return string The quoted identifier or the unchanged input
*/
protected function quoteIfSimpleIdentifier(string $identifier): string
{
/** @var SimpleIdentifierQuoter $quoter */
$quoter = $this->registry->get(SimpleIdentifierQuoter::class);

return $quoter($identifier, fn(string $part) => $this->quoteIdentifier($part));
}

/**
* Replace identifier markers in a condition string with
* dialect-quoted identifiers
*
* @param string $condition The condition string possibly containing markers
* @return string The condition string with all markers replaced
*/
protected function quoteMarkedIdentifiers(string $condition): string
{
/** @var IdentifierMarkerReplacer $replacer */
$replacer = $this->registry->get(IdentifierMarkerReplacer::class);

return $replacer($condition, fn(string $id) => $this->quoteIfSimpleIdentifier($id));
}

/**
Expand Down
38 changes: 37 additions & 1 deletion src/Command/Update/UpdateSqlBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
use JardisSupport\DbQuery\Query\Builder\Clause\LimitBuilder;
use JardisSupport\DbQuery\Query\Builder\Clause\OrderByBuilder;
use JardisSupport\DbQuery\Factory\BuilderRegistry;
use JardisSupport\DbQuery\Query\Formatter\IdentifierMarkerReplacer;
use JardisSupport\DbQuery\Query\Formatter\PlaceholderReplacer;
use JardisSupport\DbQuery\Query\Formatter\SimpleIdentifierQuoter;
use JardisSupport\DbQuery\Query\Formatter\ValueFormatter;
use JardisSupport\DbQuery\Query\Processor\JsonPlaceholderProcessor;
use JardisSupport\DbQuery\Query\Validator\SqlInjectionValidator;
Expand Down Expand Up @@ -216,6 +218,7 @@ protected function buildWhere(bool $prepared): string
$prepared,
fn(string $cond) => $this->processJsonPlaceholders($cond),
fn(string $cond) => $this->replaceSubqueryPlaceholders($cond),
fn(string $cond) => $this->quoteMarkedIdentifiers($cond),
$this->bindings
);
}
Expand All @@ -234,7 +237,40 @@ protected function buildOrderBy(): string
/** @var OrderByBuilder $builder */
$builder = $this->registry->get(OrderByBuilder::class);

return $builder($this->state);
return $builder(
$this->state,
fn(string $id) => $this->quoteIfSimpleIdentifier($id)
);
}

/**
* Quote a candidate string when it is a simple identifier
* (`ident` or `alias.ident`), otherwise return it unchanged
*
* @param string $identifier The candidate string
* @return string The quoted identifier or the unchanged input
*/
protected function quoteIfSimpleIdentifier(string $identifier): string
{
/** @var SimpleIdentifierQuoter $quoter */
$quoter = $this->registry->get(SimpleIdentifierQuoter::class);

return $quoter($identifier, fn(string $part) => $this->quoteIdentifier($part));
}

/**
* Replace identifier markers in a condition string with
* dialect-quoted identifiers
*
* @param string $condition The condition string possibly containing markers
* @return string The condition string with all markers replaced
*/
protected function quoteMarkedIdentifiers(string $condition): string
{
/** @var IdentifierMarkerReplacer $replacer */
$replacer = $this->registry->get(IdentifierMarkerReplacer::class);

return $replacer($condition, fn(string $id) => $this->quoteIfSimpleIdentifier($id));
}

/**
Expand Down
4 changes: 3 additions & 1 deletion src/DbQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,9 @@ public function groupBy(string ...$columns): self

public function having(string $expression, ?string $openBracket = null): DbQueryConditionBuilderInterface
{
$this->queryCondition->initCondition($openBracket ?? '', $expression, true);
$resolvedExpression = $this->registry->get(Method\ResolveField::class)($expression);

$this->queryCondition->initCondition($openBracket ?? '', $resolvedExpression, true);

return $this->queryCondition;
}
Expand Down
6 changes: 6 additions & 0 deletions src/Query/Builder/Clause/ConditionBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ class ConditionBuilder
* @param bool $prepared Whether to use prepared statement mode
* @param callable $processJsonPlaceholders Callback to process JSON placeholders: fn(string): string
* @param callable $replaceSubqueryPlaceholders Callback to replace subquery placeholders: fn(string): string
* @param callable $quoteMarkedIdentifiers Callback to replace identifier markers with
* dialect-quoted identifiers: fn(string): string
* @param array<int|string, mixed> &$bindings Reference to bindings array (modified in place)
* @return string The processed conditions string
*/
Expand All @@ -38,6 +40,7 @@ public function __invoke(
bool $prepared,
callable $processJsonPlaceholders,
callable $replaceSubqueryPlaceholders,
callable $quoteMarkedIdentifiers,
array &$bindings
): string {
$result = '';
Expand All @@ -47,6 +50,9 @@ public function __invoke(

foreach ($conditions as $condition) {
if (is_string($condition)) {
// Replace identifier markers with dialect-quoted identifiers
$condition = $quoteMarkedIdentifiers($condition);

// Process string conditions and replace subquery placeholders if in prepared mode
if ($prepared) {
$condition = $replaceSubqueryPlaceholders($condition);
Expand Down
19 changes: 15 additions & 4 deletions src/Query/Builder/Clause/GroupByBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,24 @@ class GroupByBuilder
/**
* Builds GROUP BY clause
*
* Columns that are simple identifiers are quoted; expressions stay untouched.
*
* @param QueryState $state The query state containing groupBy columns
* @param callable $quoteSimpleIdentifier Callback quoting a simple identifier
* or returning the input unchanged: fn(string): string
* @return string The GROUP BY clause
*/
public function __invoke(QueryState $state): string
public function __invoke(QueryState $state, callable $quoteSimpleIdentifier): string
{
return !empty($state->getGroupBy())
? ' GROUP BY ' . implode(', ', $state->getGroupBy())
: '';
if (empty($state->getGroupBy())) {
return '';
}

$columns = array_map(
static fn(string $column): string => $quoteSimpleIdentifier($column),
$state->getGroupBy()
);

return ' GROUP BY ' . implode(', ', $columns);
}
}
26 changes: 22 additions & 4 deletions src/Query/Builder/Clause/OrderByBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,31 @@ class OrderByBuilder
/**
* Builds ORDER BY clause
*
* Each entry is stored as '<field> <ASC|DESC>'. The field part is quoted
* when it is a simple identifier; expressions stay untouched.
*
* @param OrderByStateInterface $state The query state containing orderBy columns
* @param callable $quoteSimpleIdentifier Callback quoting a simple identifier
* or returning the input unchanged: fn(string): string
* @return string The ORDER BY clause
*/
public function __invoke(OrderByStateInterface $state): string
public function __invoke(OrderByStateInterface $state, callable $quoteSimpleIdentifier): string
{
return !empty($state->getOrderBy())
? ' ORDER BY ' . implode(', ', $state->getOrderBy())
: '';
if (empty($state->getOrderBy())) {
return '';
}

$entries = array_map(
static function (string $entry) use ($quoteSimpleIdentifier): string {
if (preg_match('/^(.*\S)(\s+)(ASC|DESC)$/', $entry, $matches) === 1) {
return $quoteSimpleIdentifier($matches[1]) . $matches[2] . $matches[3];
}

return $entry;
},
$state->getOrderBy()
);

return ' ORDER BY ' . implode(', ', $entries);
}
}
5 changes: 4 additions & 1 deletion src/Query/Builder/Clause/SelectBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ class SelectBuilder
* @param string $dialect The SQL dialect (mysql, postgres, sqlite)
* @param bool $prepared Whether to use prepared statement mode
* @param callable $quoteIdentifier Callback to quote identifiers: fn(string): string
* @param callable $quoteFieldList Callback quoting simple identifiers in the
* field list, leaving everything else untouched: fn(string): string
* @param array<int|string, mixed> &$bindings Reference to bindings array (modified in place)
* @return string The SELECT clause
*/
Expand All @@ -30,10 +32,11 @@ public function __invoke(
string $dialect,
bool $prepared,
callable $quoteIdentifier,
callable $quoteFieldList,
array &$bindings
): string {
$distinct = $state->isDistinct() ? 'DISTINCT ' : '';
$selectFields = trim($state->getFields());
$selectFields = $quoteFieldList(trim($state->getFields()));

$additionalFields = [];

Expand Down
Loading