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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,14 @@ $logs = $query->getResult();

The generated SQL still goes through `$wpdb->prepare()`.

Keep dynamic values out of query strings. The builder validates mapped field
paths and prepares parameter values, but it cannot make interpolated strings
safe. Put request data in `setParameter()` or `setParameters()` instead of
concatenating it into `where()`, `andWhere()`, `orWhere()`, `having()`,
`andHaving()`, `join()` conditions, or DQL strings. `orderBy()` only accepts
mapped field paths; choose from an allow-list before passing user-controlled
sort fields.

Supported query builder features:

- `select()`
Expand Down
22 changes: 12 additions & 10 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,14 @@
}
],
"require": {
"php": "^8.4",
"php": "^8.5",
"ext-json": "*",
"symfony/console": "^8.0"
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^10.5",
"sympress/coding-standards": "dev-main",
"sympress/kernel": "dev-main",
"sympress/migration": "dev-main"
"sympress/migration": "dev-main",
"sympress/qa": "dev-main"
},
"autoload": {
"psr-4": {
Expand All @@ -41,19 +39,22 @@
"scripts": {
"cs": [
"Composer\\Config::disableProcessTimeout",
"phpcs --standard=phpcs.xml.dist"
"qa cs"
],
"cs:fix": [
"Composer\\Config::disableProcessTimeout",
"phpcbf --standard=phpcs.xml.dist"
"qa cs:fix"
],
"static-analysis": [
"Composer\\Config::disableProcessTimeout",
"phpstan analyse --memory-limit=1G --no-progress -c phpstan.neon.dist"
"qa static-analysis"
],
"tests": [
"Composer\\Config::disableProcessTimeout",
"phpunit --configuration phpunit.xml.dist --no-coverage"
"qa tests"
],
"test": [
"@tests"
],
"qa": [
"@cs",
Expand All @@ -66,7 +67,8 @@
"optimize-autoloader": true,
"preferred-install": "dist",
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
"dealerdirect/phpcodesniffer-composer-installer": true,
"phpstan/extension-installer": true
}
},
"repositories": [
Expand Down
6 changes: 4 additions & 2 deletions docs/api-and-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The SymPress ORM package provides Doctrine-inspired persistence primitives for W

### EntityManager

`SymPress\Orm\EntityManager` is the primary application API. It owns the unit of work, metadata access, repositories, query creation, events, optional second-level cache, and a normalized database connection.
`SymPress\Orm\EntityManager` is the primary application API. It owns the unit of work, metadata access, repositories, query creation, events, and optional second-level cache. Database connection normalization is delegated to `Dbal\ConnectionProvider`, while custom DQL functions and SQL output walkers are kept in `Query\DqlExtensionRegistry`.

Typical usage:

Expand All @@ -29,7 +29,7 @@ $entityManager->persist($log);
$entityManager->flush();
```

When no `wpdb` instance is passed, the ORM resolves the global `$wpdb` lazily through `WpdbConnection`.
When no `wpdb` instance is passed, the ORM resolves the global `$wpdb` lazily through `ConnectionProvider` and `WpdbConnection`.

### UnitOfWork

Expand Down Expand Up @@ -96,6 +96,8 @@ Supported DQL is a focused subset:

`ORDER BY` accepts mapped field paths only, for example `l.createdAt`. Raw SQL fragments are rejected.

Do not interpolate request values into `where()`, `having()`, join conditions, or DQL strings. Those APIs accept expression snippets so mapped fields can be compiled, but dynamic values are only safe when bound through named or positional parameters. User-controlled sort choices should be mapped through an allow-list before calling `orderBy()` or `addOrderBy()`.

### SchemaTool

`SchemaTool` produces deterministic SQL for create, update, drop, validation, and schema hashes. It is used directly by console commands and by the migration bridge.
Expand Down
2 changes: 1 addition & 1 deletion phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<arg value="sp"/>
<arg name="basepath" value="."/>

<config name="testVersion" value="8.4-"/>
<config name="testVersion" value="8.5-"/>
<config name="text_domain" value="orm"/>

<file>src</file>
Expand Down
30 changes: 30 additions & 0 deletions src/Dbal/ConnectionProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace SymPress\Orm\Dbal;

final class ConnectionProvider
{
public function __construct(private ?ConnectionInterface $connection = null)
{
}

public static function fromDatabase(ConnectionInterface|\wpdb|null $database): self
{
if ($database instanceof ConnectionInterface) {
return new self($database);
}

if ($database instanceof \wpdb) {
return new self(new WpdbConnection($database));
}

return new self();
}

public function connection(): ConnectionInterface
{
return $this->connection ??= new WpdbConnection();
}
}
19 changes: 11 additions & 8 deletions src/Dbal/WpdbConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,15 @@ public function prepare(string $sql, mixed ...$parameters): string
return $sql;
}

return $this->database()->prepare($sql, ...$parameters);
// SQL is generated by the ORM/query builder layer before it reaches this WordPress adapter.
// @phpstan-ignore argument.type
$prepared = $this->database()->prepare($sql, ...$parameters);

if (!is_string($prepared)) {
throw new \RuntimeException('Unable to prepare SQL statement.');
}

return $prepared;
}

public function fetchOne(string $sql, mixed ...$parameters): mixed
Expand All @@ -50,7 +58,7 @@ public function fetchOne(string $sql, mixed ...$parameters): mixed
public function fetchAllAssociative(string $sql, mixed ...$parameters): array
{
/** @var list<array<string, mixed>> $rows */
$rows = $this->database()->get_results($this->prepare($sql, ...$parameters), $this->arrayOutput());
$rows = $this->database()->get_results($this->prepare($sql, ...$parameters), ARRAY_A);

return $rows;
}
Expand All @@ -75,7 +83,7 @@ public function executeStatement(string $sql, mixed ...$parameters): bool|int
return $this->database()->query($this->prepare($sql, ...$parameters));
}

public function lastInsertId(): int|string
public function lastInsertId(): int
{
return $this->database()->insert_id;
}
Expand Down Expand Up @@ -132,9 +140,4 @@ private function database(): \wpdb

throw new \RuntimeException('Global $wpdb is not available.');
}

private function arrayOutput(): string
{
return defined('ARRAY_A') ? constant('ARRAY_A') : 'ARRAY_A';
}
}
72 changes: 19 additions & 53 deletions src/EntityManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
use SymPress\Orm\Collection\Collection;
use SymPress\Orm\Collection\PersistentCollection;
use SymPress\Orm\Cache\CacheInterface;
use SymPress\Orm\Dbal\ConnectionProvider;
use SymPress\Orm\Dbal\ConnectionInterface;
use SymPress\Orm\Dbal\WpdbConnection;
use SymPress\Orm\Exception\EntityManagerClosedException;
use SymPress\Orm\Exception\OptimisticLockException;
use SymPress\Orm\Exception\PessimisticLockException;
Expand All @@ -27,6 +27,7 @@
use SymPress\Orm\Metadata\MetadataFactory;
use SymPress\Orm\Query\CompiledQuery;
use SymPress\Orm\Query\DqlCompiler;
use SymPress\Orm\Query\DqlExtensionRegistry;
use SymPress\Orm\Query\Query;
use SymPress\Orm\Query\QueryBuilder;

Expand All @@ -37,7 +38,7 @@ final class EntityManager

private UnitOfWork $unitOfWork;

private ?ConnectionInterface $connection;
private ConnectionProvider $connections;

private EventManager $events;

Expand All @@ -49,11 +50,7 @@ final class EntityManager
/** @var array<class-string, object> */
private array $entityListenerInstances = [];

/** @var array<string, callable(string): string> */
private array $dqlFunctions = [];

/** @var list<callable(string): string> */
private array $outputWalkers = [];
private DqlExtensionRegistry $dqlExtensions;

/** @var array<string, int> */
private array $cacheRegionVersions = [];
Expand All @@ -68,12 +65,15 @@ public function __construct(
?UnitOfWork $unitOfWork = null,
?EventManager $events = null,
?CacheInterface $secondLevelCache = null,
?ConnectionProvider $connectionProvider = null,
?DqlExtensionRegistry $dqlExtensions = null,
) {

$this->unitOfWork = $unitOfWork ?? new UnitOfWork();
$this->connection = $this->normalizeConnection($database);
$this->connections = $connectionProvider ?? ConnectionProvider::fromDatabase($database);
$this->events = $events ?? new EventManager();
$this->secondLevelCache = $secondLevelCache;
$this->dqlExtensions = $dqlExtensions ?? new DqlExtensionRegistry();
}

/** @param class-string $entityClass */
Expand Down Expand Up @@ -453,9 +453,9 @@ public function createQueryBuilder(): QueryBuilder

public function createNativeQuery(CompiledQuery $query): Query
{
if ($this->outputWalkers !== []) {
if ($this->dqlExtensions->hasOutputWalkers()) {
$query = new CompiledQuery(
$this->applyOutputWalkers($query->sql),
$this->dqlExtensions->applyOutputWalkers($query->sql),
$query->parameters,
$query->resultMetadata,
);
Expand Down Expand Up @@ -509,42 +509,22 @@ public function getConnection(): ConnectionInterface

public function registerDqlFunction(string $name, callable $compiler): void
{
$this->dqlFunctions[strtoupper($name)] = $compiler;
$this->dqlExtensions->registerFunction($name, $compiler);
}

public function addOutputWalker(callable $walker): void
{
$this->outputWalkers[] = $walker;
$this->dqlExtensions->addOutputWalker($walker);
}

public function compileDqlFunctions(string $expression): string
{
if ($this->dqlFunctions === []) {
return $expression;
}

return preg_replace_callback(
'/\b([A-Z_][A-Z0-9_]*)\s*\(([^()]*)\)/i',
function (array $matches): string {
$compiler = $this->dqlFunctions[strtoupper($matches[1])] ?? null;

if (!is_callable($compiler)) {
return $matches[0];
}

return $compiler(trim($matches[2]));
},
$expression,
) ?? $expression;
return $this->dqlExtensions->compileFunctions($expression);
}

public function applyOutputWalkers(string $sql): string
{
foreach ($this->outputWalkers as $walker) {
$sql = $walker($sql);
}

return $sql;
return $this->dqlExtensions->applyOutputWalkers($sql);
}

/** @param class-string|null $className */
Expand Down Expand Up @@ -683,7 +663,6 @@ private function updateEntity(object $entity, ClassMetadata $metadata, array $ch
if ($version instanceof ColumnMetadata) {
$where[$version->columnName] = $original[$version->columnName] ?? null;
$nextVersion = (int) ($original[$version->columnName] ?? 0) + 1;
$this->hydrator->assign($entity, $version->propertyName, $nextVersion);
$changes[$version->columnName] = $nextVersion;
}

Expand All @@ -704,6 +683,10 @@ private function updateEntity(object $entity, ClassMetadata $metadata, array $ch
if ($version instanceof ColumnMetadata && $result === 0) {
throw OptimisticLockException::lockFailed($entity);
}

if ($version instanceof ColumnMetadata) {
$this->hydrator->assign($entity, $version->propertyName, $nextVersion);
}
}

private function deleteEntity(object $entity, ClassMetadata $metadata): void
Expand Down Expand Up @@ -1691,24 +1674,7 @@ private function hydrateRows(ClassMetadata $metadata, array $rows): array

private function connection(): ConnectionInterface
{
if ($this->connection instanceof ConnectionInterface) {
return $this->connection;
}

return $this->connection = new WpdbConnection();
}

private function normalizeConnection(ConnectionInterface|\wpdb|null $database): ?ConnectionInterface
{
if ($database instanceof ConnectionInterface) {
return $database;
}

if ($database instanceof \wpdb) {
return new WpdbConnection($database);
}

return null;
return $this->connections->connection();
}

/** @param array<string|int, mixed> $parameters */
Expand Down
Loading