diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml
index 2df8b3f093..ee4bba0405 100644
--- a/.github/workflows/benchmark.yml
+++ b/.github/workflows/benchmark.yml
@@ -27,6 +27,7 @@ jobs:
if: ${{ github.event.pull_request.base.ref == github.event.repository.default_branch }}
name: Performance Regression Check
runs-on: ubuntu-latest
+ timeout-minutes: 180
steps:
- name: Checkout PR branch
@@ -44,23 +45,35 @@ jobs:
- name: Install dependencies
uses: ramsey/composer-install@v3
- - name: Benchmark base branch
+ - name: Check out base branch
run: |
git checkout ${{ github.event.pull_request.base.sha }}
cp .env.example .env
sed -i 's/^INTERNAL_CACHES=.*/INTERNAL_CACHES=false/' .env
sed -i 's/^DISCOVERY_CACHE=.*/DISCOVERY_CACHE=false/' .env
- composer update --no-interaction --prefer-dist --quiet
- vendor/bin/phpbench run --tag=base --store --progress=none
- - name: Benchmark PR branch
+ - name: Install base branch dependencies
+ timeout-minutes: 20
+ run: composer update --no-interaction --prefer-dist --quiet
+
+ - name: Benchmark base branch
+ timeout-minutes: 70
+ run: vendor/bin/phpbench run --tag=base --store --progress=none
+
+ - name: Check out PR branch
run: |
git checkout ${{ github.event.pull_request.head.sha }}
cp .env.example .env
sed -i 's/^INTERNAL_CACHES=.*/INTERNAL_CACHES=false/' .env
sed -i 's/^DISCOVERY_CACHE=.*/DISCOVERY_CACHE=false/' .env
- composer update --no-interaction --prefer-dist --quiet
- vendor/bin/phpbench run --tag=pr --store --ref=base --report=aggregate --progress=none --output=benchmark-comment | tee benchmark-result.txt || true
+
+ - name: Install PR branch dependencies
+ timeout-minutes: 20
+ run: composer update --no-interaction --prefer-dist --quiet
+
+ - name: Benchmark PR branch
+ timeout-minutes: 70
+ run: vendor/bin/phpbench run --tag=pr --store --ref=base --report=aggregate --progress=none --output=benchmark-comment | tee benchmark-result.txt || true
- name: Prepare artifact
run: |
diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
index 0ab2c4d504..0fd2c3017c 100644
--- a/.github/workflows/integration-tests.yml
+++ b/.github/workflows/integration-tests.yml
@@ -53,6 +53,19 @@ jobs:
phpunit:
runs-on: ${{ matrix.os }}
+ services:
+ mysql:
+ image: ${{ matrix.database == 'mysql' && 'mysql:8.0' || '' }}
+ env:
+ MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
+ MYSQL_DATABASE: app
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="mysqladmin ping -h 127.0.0.1 --silent"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=5
strategy:
fail-fast: false
matrix:
@@ -97,20 +110,13 @@ jobs:
if: ${{ matrix.os != 'windows-latest' }}
uses: supercharge/redis-github-action@1.8.1
- - name: "Setup MySQL"
- if: ${{ matrix.database == 'mysql' }}
- uses: ankane/setup-mysql@v1
- with:
- mysql-version: 8.0
- database: "app"
-
- name: "Setup PostgreSQL"
if: ${{ matrix.database == 'postgres' }}
uses: ankane/setup-postgres@v1
- name: Create worker databases - mysql
if: ${{ matrix.database == 'mysql' }}
- run: for i in 1 2 3 4; do mysql -u root -e "CREATE DATABASE IF NOT EXISTS app$i;"; done
+ run: for i in 1 2 3 4; do docker exec "${{ job.services.mysql.id }}" mysql -u root -e "CREATE DATABASE IF NOT EXISTS app$i;"; done
- name: Create worker databases - postgres
if: ${{ matrix.database == 'postgres' }}
diff --git a/.gitignore b/.gitignore
index 78921a9b77..300b99e6ba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
# Top level directories
/log/
+/bench/
/package-lock.json
# Top-level+Sub directories
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 538322b7a4..62b8ab05cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,61 @@
All notable changes to this project will be documented in this file.
-## [3.19.2](https://github.com/tempestphp/tempest-framework/compare/v3.19.1..3.19.2) — 2026-09-08
+## [3.21.0](https://github.com/tempestphp/tempest-framework/compare/v3.20.0..3.21.0) — 2026-10-29
+
+### 🚨 Breaking changes
+
+- **http**: [**breaking**] regenerate the session identifier on authentication (#2284) ([2f83459](https://github.com/tempestphp/tempest-framework/commit/2f83459d951fec13414b4c4b6d99b94771b17dcc))
+
+### 🚀 Features
+
+- **database**: support creating database configs from a URL (#2315) ([809b73b](https://github.com/tempestphp/tempest-framework/commit/809b73bc10ee72d128be078468b3642cf6f3d1bf))
+
+### ⚡ Performance
+
+- **http**: find expired file sessions by modification time (#2317) ([577f8dc](https://github.com/tempestphp/tempest-framework/commit/577f8dcfe8e7988cc8abcd0960a3ea4b4c519436))
+- improve session handling (#2318) ([1509573](https://github.com/tempestphp/tempest-framework/commit/1509573a2148600c284435dde8761638d30aa2b9))
+
+### 🐛 Bug fixes
+
+- **cryptography**: measure timelock elapsed time with the clock (#2321) ([86b06a9](https://github.com/tempestphp/tempest-framework/commit/86b06a9455206fef95386d07aa7dd2f9a1e1a6a0))
+- **database**: resolve uuid primary keys from positional insert bindings (#2301) ([c68e0ac](https://github.com/tempestphp/tempest-framework/commit/c68e0ac2c31364125c367ffa8ed9f26b0d2b28cd))
+- **database**: declare TModel template on where-builder traits (#2322) ([f759cdc](https://github.com/tempestphp/tempest-framework/commit/f759cdca1da965aa1bae881c4b168e33dc9252e5))
+
+
+## [3.20.0](https://github.com/tempestphp/tempest-framework/compare/v3.19.3..v3.20.0) — 2026-10-22
+
+### 🚀 Features
+
+- **database**: support unsigned primary keys on MySQL (#2312) ([d4459e0](https://github.com/tempestphp/tempest-framework/commit/d4459e0c205f5f30bbcaf66bdde7650f61c1a6d3))
+
+### 🐛 Bug fixes
+
+- **container**: detect nested circular dependencies (#2297) ([f869ea7](https://github.com/tempestphp/tempest-framework/commit/f869ea70ae16f16db6dc1991a60d5982e6164020))
+
+
+## [3.19.3](https://github.com/tempestphp/tempest-framework/compare/v3.19.2..v3.19.3) — 2026-10-22
+
+### 🚀 Features
+
+- **container**: add dynamic tags (#2288) ([aea7f2b](https://github.com/tempestphp/tempest-framework/commit/aea7f2b9127f26120cf662a5f78f047f7e639efb))
+
+### ⚡ Performance
+
+- **mapper**: cache per-class reflection plans during hydration (#2276) ([952b707](https://github.com/tempestphp/tempest-framework/commit/952b70770fc64585b7c6557e1d150d5c243f168c))
+
+### 🐛 Bug fixes
+
+- **console**: properly restore terminal after testing (#2294) ([0061698](https://github.com/tempestphp/tempest-framework/commit/006169824ba6ae00edd0d87662f01bc6baddc401))
+- **container**: fix dynamic tag oversights (#2291) ([06addeb](https://github.com/tempestphp/tempest-framework/commit/06addeb3076f995365c5960ea00af4fd596f9155))
+- **database**: don't emit `UNSIGNED` on Postgres (#2295) ([0f478fb](https://github.com/tempestphp/tempest-framework/commit/0f478fb487e12a5891738a15a1663fa44c47295b))
+- **database**: qualify orderBy fields against the base table (#2306) ([c9949b3](https://github.com/tempestphp/tempest-framework/commit/c9949b3dd6c96a656cf7881c130f1988e2ea4a94))
+- **framework**: read one-line docblock vars in meta:view-component (#2299) ([a14c2f3](https://github.com/tempestphp/tempest-framework/commit/a14c2f34ab7d38d5326ed58a0781e942a1d0f970))
+- **mapper**: materialize promoted defaults for readonly properties (#2304) ([05c7376](https://github.com/tempestphp/tempest-framework/commit/05c73765c7f1c65dfa908fd81f71768686105254))
+- dependencies and CI issues (#2314) ([854954b](https://github.com/tempestphp/tempest-framework/commit/854954b40f2fe65d1d1b5f5cd8c893b6c266a0af))
+
+
+## [3.19.2](https://github.com/tempestphp/tempest-framework/compare/v3.19.1..v3.19.2) — 2026-09-08
### 🚀 Features
diff --git a/composer.json b/composer.json
index f96a0580c4..0cc80e49d3 100644
--- a/composer.json
+++ b/composer.json
@@ -17,14 +17,11 @@
"giggsey/libphonenumber-for-php-lite": "^9.0",
"guzzlehttp/guzzle": "^7.8.2",
"laminas/laminas-diactoros": "^3.3",
- "league/commonmark": "^2.7",
"league/flysystem": "^3.29.1",
"league/mime-type-detection": "^1.16",
"league/oauth2-client": "^2.8",
"monolog/monolog": "^3.7.0",
- "nette/php-generator": "4.2.1",
- "nette/schema": "^1.3.4",
- "nikic/php-parser": "^5.3",
+ "nikic/php-parser": "^5.9",
"php": "^8.5",
"psr-discovery/http-client-implementations": "^1.4",
"psr-discovery/http-factory-implementations": "^1.2",
@@ -45,6 +42,7 @@
"symfony/var-exporter": "^7.1|^8.0",
"symfony/yaml": "^7.3|^8.0",
"tempest/highlight": "^2.11.4",
+ "tempest/markdown": "^1.3",
"vlucas/phpdotenv": "^5.6.1",
"voku/portable-ascii": "^2.0.3"
},
diff --git a/docs/1-essentials/01-routing.md b/docs/1-essentials/01-routing.md
index d6788bf7dc..962e2be85b 100644
--- a/docs/1-essentials/01-routing.md
+++ b/docs/1-essentials/01-routing.md
@@ -979,6 +979,8 @@ return new CookieConfig(
Sessions in Tempest are managed by the {b`Tempest\Http\Session\Session`} class. It can be injected anywhere needed. As soon as the {b`Tempest\Http\Session\Session`} is injected, it is started behind the scenes.
+New sessions are only persisted once something is stored in them. This way, clients that don't keep cookies, such as API consumers or crawlers, don't fill up the session storage. Likewise, sessions that didn't change during a request aren't written again; only their activity is tracked.
+
```php
use Tempest\Http\Session\Session;
@@ -1062,7 +1064,7 @@ return new DatabaseSessionConfig(
Sessions expire based on the last activity time. This means that as long as a user is actively using the application, their session remains valid.
-By default, Tempest removes expired session randomly at the end of a request, in a deferred task. This can be configured by specifying a {b`Tempest\Http\Session\CleanupStrategy`} in the [session configuration](#session-configuration).
+By default, Tempest removes expired sessions at the end of a random request, in a deferred task. To keep the cost of cleaning up from growing with traffic, this happens at most once per minute. This can be configured by specifying a {b`Tempest\Http\Session\CleanupStrategy`} in the [session configuration](#session-configuration).
```php app/Sessions/session.config.php
use Tempest\Http\Session\Config\DatabaseSessionConfig;
diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md
index 4af4ef95c6..1403e3b4a1 100644
--- a/docs/1-essentials/03-database.md
+++ b/docs/1-essentials/03-database.md
@@ -36,6 +36,40 @@ return new PostgresConfig(
);
```
+### Connecting with a connection string
+
+Some hosting providers, such as Laravel Cloud, inject the connection details as a single `DATABASE_URL` environment variable. Each configuration class provides a `fromConnectionString()` method that builds the configuration from such a connection string:
+
+```php app/Config/database.config.php
+use Tempest\Database\Config\MysqlConfig;
+use function Tempest\env;
+
+return MysqlConfig::fromConnectionString(env('DATABASE_URL'));
+```
+
+Components missing from the connection string fall back to their defaults. Any other constructor argument may be passed by name, and takes precedence over the values parsed from the connection string:
+
+```php app/Config/database.config.php
+return PostgresConfig::fromConnectionString(env('DATABASE_URL'), persistent: true);
+```
+
+Query parameters are appended to the DSN, which allows passing driver-specific options such as `sslmode` or `connect_timeout`:
+
+```
+postgres://user:secret@db.example.com:5432/app?sslmode=require&connect_timeout=10
+```
+
+To connect through a Unix socket, pass its path as a percent-encoded host, or as the `host` query parameter for PostgreSQL and the `unix_socket` query parameter for MySQL:
+
+```
+postgres://user:secret@%2Fvar%2Frun%2Fpostgresql/app
+mysql://user:secret@localhost/app?unix_socket=/var/run/mysqld/mysqld.sock
+```
+
+{b`Tempest\Database\Config\MysqlConfig`} accepts the `mysql` and `mariadb` schemes, {b`Tempest\Database\Config\PostgresConfig`} accepts `postgres`, `postgresql` and `pgsql`, and {b`Tempest\Database\Config\SQLiteConfig`} accepts connection strings such as `sqlite:///path/to/database.sqlite` and `sqlite::memory:`. Credentials containing special characters must be percent-encoded.
+
+The parsed components are also available through the {b`Tempest\Database\Config\DatabaseConnectionString`} value object, using `DatabaseConnectionString::from()`.
+
## Querying the database
Multiple approaches exist for querying the database, all of which execute a {b`Tempest\Database\Query`} on the {b`Tempest\Database\Database`} class. The most straightforward approach is to inject {b`Tempest\Database\Database`}:
@@ -406,10 +440,61 @@ final class CreateBooksTable implements MigratesUp
}
```
+Columns referencing a UUID primary key need a UUID-compatible type. Use `belongsToUuid()` and `foreignUuid()` as UUID counterparts of `belongsTo()` and `foreignId()`:
+
+```php app/Books/CreateChaptersTable.php
+use Tempest\Database\MigratesUp;
+use Tempest\Database\QueryStatement;
+use Tempest\Database\QueryStatements\CreateTableStatement;
+use Tempest\Database\QueryStatements\OnDelete;
+
+final class CreateChaptersTable implements MigratesUp
+{
+ public string $name = '2024-08-13_create_chapters_table';
+
+ public function up(): QueryStatement
+ {
+ return new CreateTableStatement('chapters')
+ ->uuid()
+ ->text('title')
+ ->foreignUuid('book_uuid', constrainedOn: 'books.uuid', onDelete: OnDelete::CASCADE);
+ }
+}
+```
+
+UUID columns use `CHAR(36)` on MySQL, `UUID` on PostgreSQL, and `TEXT` on SQLite, so foreign keys referencing UUID primary keys are type-compatible on every supported dialect.
+
:::warning
Currently, the [`IsDatabaseModel`](#the-is-database-model-trait) trait already provides a primary `$id` property. It is therefore not possible to use UUIDs alongside `IsDatabaseModel`.
:::
+### Using unsigned integers as primary keys
+
+By default, Tempest uses signed auto-incrementing integers as primary keys. On MySQL, you may use unsigned integers instead by specifying `unsigned: true` to the `primary()` method. Foreign keys referencing an unsigned primary key must be unsigned as well, so specify `unsigned: true` to `belongsTo()` or `foreignId()` too:
+
+```php app/Books/CreateBooksTable.php
+use Tempest\Database\MigratesUp;
+use Tempest\Database\QueryStatement;
+use Tempest\Database\QueryStatements\CreateTableStatement;
+
+final class CreateBooksTable implements MigratesUp
+{
+ public string $name = '2024-08-12_create_books_table';
+
+ public function up(): QueryStatement
+ {
+ return new CreateTableStatement('books')
+ ->primary(unsigned: true)
+ ->foreignId('author_id', constrainedOn: 'authors', unsigned: true)
+ ->text('title');
+ }
+}
+```
+
+:::info
+PostgreSQL and SQLite have no unsigned integer type, so this option has no effect on primary keys for those databases.
+:::
+
### Table names
Tempest infers the table name for a model class based on the model's classname. By default, the table name is the pluralized, `snake_cased` version of the base class name. This can be overridden using the {b`Tempest\Database\Table`} attribute:
diff --git a/docs/1-essentials/05-container.md b/docs/1-essentials/05-container.md
index 71e12affae..9de0854cc5 100644
--- a/docs/1-essentials/05-container.md
+++ b/docs/1-essentials/05-container.md
@@ -245,7 +245,7 @@ final readonly class MarkdownInitializer implements Initializer
In some cases, you want more control over singleton definitions.
-Let's say you want an instance of `{php}\Tempest\Highlight\Highlighter` that would be configured for web highlighting, and one that would be configured CLI highlighting. In this situation, you can differentiate them using the `tag` parameter of the `#[Singleton]` attribute:
+Let's say you want an instance of `{php}\Tempest\Highlight\Highlighter` that would be configured for web highlighting, and one that would be configured for CLI highlighting. In this situation, you can differentiate them using the `tag` parameter of the `#[Singleton]` attribute:
```php app/WebHighlighterInitializer.php
use Tempest\Container\Container;
@@ -283,15 +283,50 @@ $container->get(Highlighter::class, tag: 'cli');
```
:::info
-[This blog post](https://stitcher.io/blog/tagged-singletons), by {gh:brendt}, provides in-depth explanations about tagged singletons.
+[This blog post](https://stitcher.io/blog/tagged-singletons) provides in-depth explanations about tagged singletons.
:::
-### Dynamic tags
+### The `HasTag` interface
Some components implement the {`Tempest\Container\HasTag`} interface, which requires a `tag` property. Singletons using this interface are tagged by the `tag` property, essentially providing the ability to have dynamic tags.
This is specifically useful to get multiple instances of the same configuration. This is how [multiple database connections support](../1-essentials/03-database.md#using-multiple-connections) is implemented.
+### Dynamic tags
+
+It's possible to allow on-the-fly tagging of certain dependencies with no pre-defined tags. However, you need to explicitly specify you want to enable dynamic tags for a given dependency with the `#[Singleton]` attribute:
+
+```php
+use Tempest\Container\Singleton;
+
+#[Singleton(dynamicTags: true)]
+final class TenantRepository
+{ /* … */ }
+```
+
+```php
+use Tempest\Container\Initializer;
+use Tempest\Container\Container;
+use Tempest\Container\Singleton;
+
+final class TenantRepositoryInitializer implements Initializer
+{
+ #[Singleton(dynamicTags: true)]
+ public function initialize(Container $container): TenantRepository
+ {
+ return new TenantRepository();
+ }
+}
+```
+
+With dynamic tags configured, you can now retrieve dependencies from the container with tags that weren't pre-configured:
+
+```php
+$repository = $container->get(TenantRepository::class, tag: $tenant->id);
+```
+
+As you might have guessed from the examples, dynamic tags are useful to build features with like multi-tenant systems.
+
## Built-in types dependencies
Besides being able to depend on objects, sometimes you'd want to depend on built-in types like `string`, `int` or more often `array`. It is possible to depend on these built-in types, but these cannot be autowired and must be initialized through a [tagged singleton](#tagged-singletons).
diff --git a/docs/2-features/02-asset-bundling.md b/docs/2-features/02-asset-bundling.md
index be2372109a..5d8cab0d49 100644
--- a/docs/2-features/02-asset-bundling.md
+++ b/docs/2-features/02-asset-bundling.md
@@ -1,7 +1,7 @@
---
title: Asset bundling
description: "Web applications usually need to serve assets to users. Tempest provide a seamless integration with Vite, the most popular front-end development server and build tool"
-keywords: ["vite", "frontend", "js", "css", "ts", "typescript", "javascript", "sri", "manifest", "assets"]
+keywords: ["vite", "frontend", "js", "css", "ts", "typescript", "javascript", "sri", "manifest", "assets", "prefetch"]
---
## Overview
@@ -102,6 +102,21 @@ By default, assets are compiled in the `public/build` directory. This directory
This directory is already in your `.gitignore` if you used the `{sh}php tempest install vite` command.
:::
+## Prefetching assets
+
+Tempest can instruct the browser to prefetch lazily loaded chunks in the background, so the user doesn't have to wait for them to download later. To enable it, configure the `prefetching` parameter in a `vite.config.php` file:
+
+```php app/vite.config.php
+return new ViteConfig(
+ prefetching: new PrefetchConfig(
+ strategy: PrefetchStrategy::WATERFALL,
+ concurrent: 3,
+ ),
+);
+```
+
+The `WATERFALL` strategy downloads `concurrent` assets at a time, while `AGGRESSIVE` downloads all of them at once. Prefetching starts on the `load` event by default, which you may change with the `prefetchEvent` parameter.
+
## Using a `nonce` attribute
If your application uses a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP), you may need to include `nonce` attributes to the tags generated by `{html}`.
diff --git a/docs/2-features/04-authentication.md b/docs/2-features/04-authentication.md
index 2e248a2f68..c8c757c2c6 100644
--- a/docs/2-features/04-authentication.md
+++ b/docs/2-features/04-authentication.md
@@ -90,6 +90,39 @@ final readonly class AuthenticationController
}
```
+### Regenerating the session identifier
+
+Tempest automatically regenerates the session identifier when a model is authenticated or deauthenticated. Authentication keeps the existing session data, while deauthentication clears it before creating the new session. In both cases, the previous session is destroyed.
+
+You should also regenerate the session identifier whenever an authenticated session changes privilege level, such as after a password change, enabling two-factor authentication, impersonating another user, or escalating a user's role. Use the `regenerate()` method on {b`Tempest\Http\Session\SessionManager`} for these transitions:
+
+```php app/Authentication/TwoFactorController.php
+use Tempest\Http\Session\Session;
+use Tempest\Http\Session\SessionManager;
+
+final readonly class TwoFactorController
+{
+ public function __construct(
+ private Session $session,
+ private SessionManager $sessionManager,
+ ) {}
+
+ public function enable(): void
+ {
+ // Enable two-factor authentication for the current user...
+
+ $this->sessionManager->regenerate($this->session);
+ }
+}
+```
+
+`regenerate()` destroys the old session, assigns a new identifier, carries the session data over and saves it. If the data must not survive the transition, clear the session before regenerating it:
+
+```php
+$this->session->clear();
+$this->sessionManager->regenerate($this->session);
+```
+
### Accessing the authenticated model
You may access the currently authenticated model by injecting the {b`Tempest\Auth\Authentication\Authenticator`}. The authenticator provides a `current()` method that returns the currently authenticated model, or `null` if no model is authenticated.
diff --git a/docs/5-extra-topics/01-package-development.md b/docs/5-extra-topics/01-package-development.md
index 7236c10e36..e452322011 100644
--- a/docs/5-extra-topics/01-package-development.md
+++ b/docs/5-extra-topics/01-package-development.md
@@ -71,7 +71,6 @@ The user will have a chance to specify the destination of the file, and whether
use Tempest\Core\Installer;
use Tempest\Core\PublishesFiles;
use Tempest\Discovery\SkipDiscovery;
-use Tempest\Generation\ClassManipulator;
use function Tempest\src_namespace;
use function Tempest\src_path;
diff --git a/packages/auth/src/Authentication/SessionAuthenticator.php b/packages/auth/src/Authentication/SessionAuthenticator.php
index 572b4fcfef..24d41356b8 100644
--- a/packages/auth/src/Authentication/SessionAuthenticator.php
+++ b/packages/auth/src/Authentication/SessionAuthenticator.php
@@ -43,15 +43,20 @@ public function authenticate(Authenticatable $authenticatable): void
$this->currentId = $id;
$this->currentClass = $class;
$this->current = $authenticatable;
+
+ // The session identifier must not survive a change in privilege level, or one
+ // known to an attacker before authentication stays valid afterwards.
+ $this->sessionManager->regenerate($this->session);
}
public function deauthenticate(): void
{
- $this->session->remove(self::AUTHENTICATABLE_KEY);
- $this->session->remove(self::AUTHENTICATABLE_CLASS);
$this->clearCurrent();
- $this->sessionManager->save($this->session);
+ // Discard session data so authenticated user data is not carried over
+ // to the new identifier.
+ $this->session->clear();
+ $this->sessionManager->regenerate($this->session);
}
public function current(): ?Authenticatable
diff --git a/packages/auth/tests/SessionAuthenticatorTest.php b/packages/auth/tests/SessionAuthenticatorTest.php
index 6e791e796d..69b096f5a2 100644
--- a/packages/auth/tests/SessionAuthenticatorTest.php
+++ b/packages/auth/tests/SessionAuthenticatorTest.php
@@ -13,6 +13,7 @@
use Tempest\DateTime\DateTime;
use Tempest\Http\Session\Session;
use Tempest\Http\Session\SessionId;
+use Tempest\Http\Session\SessionIdResolver;
use Tempest\Http\Session\SessionManager;
final class SessionAuthenticatorTest extends TestCase
@@ -136,6 +137,52 @@ public function authenticate_replaces_a_cached_current_authenticatable(): void
$this->assertSame(2, $current->id);
}
+ #[Test]
+ public function authenticate_regenerates_the_session_identifier(): void
+ {
+ $session = $this->createSession();
+ $sessionManager = new TestingSessionManager();
+
+ $authenticator = new SessionAuthenticator(
+ sessionManager: $sessionManager,
+ session: $session,
+ authenticatableResolver: new CountingAuthenticatableResolver(),
+ );
+
+ $authenticator->authenticate(new MemoizedAuthenticatable(id: 1));
+
+ $this->assertNotSame('test-session', (string) $session->id);
+ $this->assertSame(1, $sessionManager->deletedSessions);
+ $this->assertSame(1, $sessionManager->savedSessions);
+ $this->assertSame(1, $session->get(SessionAuthenticator::AUTHENTICATABLE_KEY));
+ }
+
+ #[Test]
+ public function deauthenticate_regenerates_the_session_identifier_and_discards_the_data(): void
+ {
+ $session = $this->createSession();
+ $session->set(SessionAuthenticator::AUTHENTICATABLE_KEY, 1);
+ $session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class);
+ $session->set('key', 'value');
+
+ $sessionManager = new TestingSessionManager();
+
+ $authenticator = new SessionAuthenticator(
+ sessionManager: $sessionManager,
+ session: $session,
+ authenticatableResolver: new CountingAuthenticatableResolver(),
+ );
+
+ $authenticator->deauthenticate();
+
+ $this->assertNotSame('test-session', (string) $session->id);
+ $this->assertSame(1, $sessionManager->deletedSessions);
+ $this->assertSame(1, $sessionManager->savedSessions);
+ $this->assertNull($session->get(SessionAuthenticator::AUTHENTICATABLE_KEY));
+ $this->assertNull($session->get(SessionAuthenticator::AUTHENTICATABLE_CLASS));
+ $this->assertNull($session->get('key'));
+ }
+
private function createSession(): Session
{
$now = DateTime::now();
@@ -186,10 +233,32 @@ public function resolveId(Authenticatable $authenticatable): int
}
}
+final class TestingSessionIdResolver implements SessionIdResolver
+{
+ public function resolve(): SessionId
+ {
+ return new SessionId('test-session');
+ }
+
+ public function issueNewId(): SessionId
+ {
+ return new SessionId('regenerated-session-' . uniqid());
+ }
+}
+
final class TestingSessionManager implements SessionManager
{
public int $savedSessions = 0;
+ public int $deletedSessions = 0;
+
+ private SessionIdResolver $sessionIdResolver;
+
+ public function __construct()
+ {
+ $this->sessionIdResolver = new TestingSessionIdResolver();
+ }
+
public function getOrCreate(SessionId $id): Session
{
$now = DateTime::now();
@@ -202,7 +271,19 @@ public function save(Session $session): void
$this->savedSessions++;
}
- public function delete(Session $session): void {}
+ public function delete(Session $session): void
+ {
+ $this->deletedSessions++;
+ }
+
+ public function regenerate(Session $session): void
+ {
+ $this->delete($session);
+
+ $session->replaceId($this->sessionIdResolver->issueNewId());
+
+ $this->save($session);
+ }
public function isValid(Session $session): bool
{
diff --git a/packages/command-bus/src/AsyncCommandRepositories/RedisCommandRepository.php b/packages/command-bus/src/AsyncCommandRepositories/RedisCommandRepository.php
index 1a22c1ce81..cfff2271b4 100644
--- a/packages/command-bus/src/AsyncCommandRepositories/RedisCommandRepository.php
+++ b/packages/command-bus/src/AsyncCommandRepositories/RedisCommandRepository.php
@@ -4,6 +4,7 @@
namespace Tempest\CommandBus\AsyncCommandRepositories;
+use Deprecated;
use Tempest\CommandBus\CommandRepository;
use Tempest\CommandBus\Exceptions\PendingCommandCouldNotBeResolved;
use Tempest\KeyValue\Redis\Redis;
@@ -17,9 +18,8 @@
/**
* Set once the old one key per command layout has been migrated, so the keyspace is scanned once.
- *
- * @deprecated Remove in 4.0, along with the key itself.
*/
+ #[Deprecated(message: 'Remove in 4.0, along with the key itself.')]
private const string MIGRATION_KEY = 'command:migrated';
/**
@@ -47,9 +47,8 @@
/**
* Moves one command from its own key into the matching hash, as a script so it ends up in exactly one.
- *
- * @deprecated Remove in 4.0.
*/
+ #[Deprecated(message: 'Remove in 4.0.')]
private const string MIGRATE_COMMAND_SCRIPT = <<<'LUA'
local command = redis.call('GET', KEYS[1])
@@ -112,9 +111,8 @@ public function markAsFailed(string $uuid): void
/**
* Moves commands that earlier versions stored under their own key into the hashes, and returns how many.
- *
- * @deprecated Remove in 4.0.
*/
+ #[Deprecated(message: 'Remove in 4.0.')]
public function migrateStoredCommands(): int
{
if ((int) $this->redis->command('EXISTS', self::MIGRATION_KEY) === 1) {
@@ -130,9 +128,8 @@ public function migrateStoredCommands(): int
/**
* Scans from the client, since Redis before 7 refuses to write after a `SCAN`.
- *
- * @deprecated Remove in 4.0.
*/
+ #[Deprecated(message: 'Remove in 4.0.')]
private function migrateLegacyKeys(string $hash): int
{
$prefix = $hash . ':';
diff --git a/packages/command-bus/src/MonitorAsyncCommands.php b/packages/command-bus/src/MonitorAsyncCommands.php
index 98d8ed9b84..89ec6cba28 100644
--- a/packages/command-bus/src/MonitorAsyncCommands.php
+++ b/packages/command-bus/src/MonitorAsyncCommands.php
@@ -5,6 +5,7 @@
namespace Tempest\CommandBus;
use DateTimeImmutable;
+use Deprecated;
use Symfony\Component\Process\Process;
use Tempest\CommandBus\AsyncCommandRepositories\RedisCommandRepository;
use Tempest\Console\Console;
@@ -108,9 +109,8 @@ public function __invoke(): void
/**
* Moves Redis commands stored by an older version over, on the first start after the upgrade.
- *
- * @deprecated Remove in 4.0.
*/
+ #[Deprecated(message: 'Remove in 4.0.')]
private function migrateStoredCommands(): void
{
if (! $this->repository instanceof RedisCommandRepository) {
diff --git a/packages/console/src/Terminal/Terminal.php b/packages/console/src/Terminal/Terminal.php
index 3f21d91a9b..35a4cf1b6a 100644
--- a/packages/console/src/Terminal/Terminal.php
+++ b/packages/console/src/Terminal/Terminal.php
@@ -143,6 +143,7 @@ public function render(InteractiveConsoleComponent $component, array $validation
public function disableTty(): self
{
+ $this->switchToNormalMode();
$this->supportsTty = false;
return $this;
diff --git a/packages/console/tests/TerminalTest.php b/packages/console/tests/TerminalTest.php
index 422c41482d..ff249f96c2 100644
--- a/packages/console/tests/TerminalTest.php
+++ b/packages/console/tests/TerminalTest.php
@@ -62,6 +62,8 @@ public function cursor_is_hidden_while_redrawing_content(): void
$terminal->disableTty();
$terminal->cursor = $cursor;
+ $events = [];
+
iterator_to_array($terminal->render(new TextInputComponent(label: 'Name')));
$this->assertSame(
diff --git a/packages/container/src/DependencyChain.php b/packages/container/src/DependencyChain.php
index 210cfb4c4e..542f46ecda 100644
--- a/packages/container/src/DependencyChain.php
+++ b/packages/container/src/DependencyChain.php
@@ -32,6 +32,19 @@ public function add(Reflector|Closure|string $dependency): self
return $this;
}
+ /**
+ * Removes the last added dependency from the active resolution chain.
+ *
+ * Because dependencies are resolved in nested order, the most recently
+ * added item is always the one that just finished.
+ */
+ public function pop(): self
+ {
+ array_pop($this->dependencies);
+
+ return $this;
+ }
+
public function first(): Dependency
{
return array_first($this->dependencies);
diff --git a/packages/container/src/GenericContainer.php b/packages/container/src/GenericContainer.php
index 213ade1112..8901e4900b 100644
--- a/packages/container/src/GenericContainer.php
+++ b/packages/container/src/GenericContainer.php
@@ -207,13 +207,15 @@ public function config(object $config): self
*/
public function get(string $className, string|UnitEnum|null $tag = null, mixed ...$params): object
{
- $this->resolveChain();
-
- $dependency = $this->resolve($className, $tag, ...$params);
+ $previousChain = $this->chain;
- $this->stopChain();
+ $this->resolveChain();
- return $dependency;
+ try {
+ return $this->resolve($className, $tag, ...$params);
+ } finally {
+ $this->stopChain($previousChain);
+ }
}
public function invoke(ClassReflector|MethodReflector|FunctionReflector|callable|string $method, mixed ...$params): mixed
@@ -250,33 +252,43 @@ public function invoke(ClassReflector|MethodReflector|FunctionReflector|callable
private function invokeClosure(Closure $closure, mixed ...$params): mixed
{
- $this->resolveChain();
+ $previousChain = $this->chain;
- $parameters = $this->autowireDependencies(
- method: $reflector = new FunctionReflector($closure),
- parameters: $params,
- );
+ $this->resolveChain();
- $this->stopChain();
+ try {
+ $parameters = $this->autowireDependencies(
+ method: $reflector = new FunctionReflector($closure),
+ parameters: $params,
+ );
+ } finally {
+ $this->stopChain($previousChain);
+ }
return $reflector->invokeArgs($parameters);
}
private function invokeMethod(MethodReflector $method, mixed ...$params): mixed
{
- $this->resolveChain();
+ $previousChain = $this->chain;
- $object = $this->get($method->getDeclaringClass()->getName());
+ $this->resolveChain();
- $parameters = $this->autowireDependencies($method, $params);
+ try {
+ $object = $this->get($method->getDeclaringClass()->getName());
- $this->stopChain();
+ $parameters = $this->autowireDependencies($method, $params);
+ } finally {
+ $this->stopChain($previousChain);
+ }
return $method->invokeArgs($object, $parameters);
}
private function invokeFunction(FunctionReflector|Closure $callback, mixed ...$params): mixed
{
+ $previousChain = $this->chain;
+
$this->resolveChain();
$reflector = match (true) {
@@ -284,9 +296,11 @@ private function invokeFunction(FunctionReflector|Closure $callback, mixed ...$p
default => new ReflectionFunction($callback),
};
- $parameters = $this->autowireDependencies($reflector, $params);
-
- $this->stopChain();
+ try {
+ $parameters = $this->autowireDependencies($reflector, $params);
+ } finally {
+ $this->stopChain($previousChain);
+ }
return $reflector->invokeArgs($parameters);
}
@@ -371,20 +385,23 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag
$dependencyName = $this->resolveTaggedName($className, $tag);
- // Check if a resolved singleton is present
+ // Check if a resolved singleton is present. A singleton is only registered once it is fully
+ // constructed, so it can never be in flight and cannot take part in a cycle.
if ($instance = $this->resolvedSingletons[$dependencyName] ?? null) {
- $this->resolveChain()->add($class);
-
return $instance;
}
// Check if the class has been registered as a singleton.
if ($singletonDefinition = $this->singletonDefinitions[$dependencyName] ?? null) {
- $instance = $singletonDefinition instanceof Closure ? $singletonDefinition($this) : $singletonDefinition;
+ $this->resolveChain()->add($class);
- $this->resolvedSingletons[$dependencyName] = $instance;
+ try {
+ $instance = $singletonDefinition instanceof Closure ? $singletonDefinition($this) : $singletonDefinition;
+ } finally {
+ $this->resolveChain()->pop();
+ }
- $this->resolveChain()->add($class);
+ $this->resolvedSingletons[$dependencyName] = $instance;
return $instance;
}
@@ -393,7 +410,11 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag
if ($definition = $this->definitions[$dependencyName] ?? null) {
$this->resolveChain()->add(new FunctionReflector($definition));
- return $definition($this);
+ try {
+ return $definition($this);
+ } finally {
+ $this->resolveChain()->pop();
+ }
}
// Next we check if any of our default initializers can initialize this class.
@@ -402,10 +423,14 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag
$this->resolveChain()->add($initializerClass);
- $object = match (true) {
- $initializer instanceof Initializer => $initializer->initialize($this->clone()),
- $initializer instanceof DynamicInitializer => $initializer->initialize($class, $tag, $this->clone()),
- };
+ try {
+ $object = match (true) {
+ $initializer instanceof Initializer => $initializer->initialize($this->clone()),
+ $initializer instanceof DynamicInitializer => $initializer->initialize($class, $tag, $this->clone()),
+ };
+ } finally {
+ $this->resolveChain()->pop();
+ }
$singleton = $initializerClass->getAttribute(Singleton::class) ?? $initializerClass->getMethod('initialize')->getAttribute(Singleton::class);
@@ -416,13 +441,20 @@ private function resolveDependency(string $className, string|UnitEnum|null $tag
return $object;
}
- // If we're requesting a tagged dependency and haven't resolved it at this point, something's wrong
+ // Check for dynamic tags
if ($tag !== null) {
- throw new TaggedDependencyCouldNotBeResolved($this->chain, new Dependency($className), $tag);
+ /** @var \Tempest\Container\Singleton|null $singleton */
+ $singleton = $class->getAttribute(Singleton::class);
+
+ if (! $singleton || ! $singleton->dynamicTags) {
+ throw new TaggedDependencyCouldNotBeResolved($this->chain, new Dependency($className), $tag);
+ }
+
+ return $this->autowire($className, $tag, ...$params);
}
// Finally, autowire the class.
- return $this->autowire($className, ...$params);
+ return $this->autowire($className, null, ...$params);
}
private function initializerForBuiltin(TypeReflector $target, string $tag): ?Initializer
@@ -446,6 +478,17 @@ private function initializerForClass(ClassReflector $target, string|UnitEnum|nul
return $this->resolve($initializerClass);
}
+ // Check if dynamic tags are allowed
+ if ($tag && ($initializerClass = $this->initializers[$target->getName()] ?? null)) {
+ $class = new ClassReflector($initializerClass);
+
+ $singleton = $class->getAttribute(Singleton::class) ?? $class->getMethod('initialize')->getAttribute(Singleton::class);
+
+ if ($singleton?->dynamicTags) {
+ return $this->resolve($class->getName());
+ }
+ }
+
// Loop through the registered initializers to see if
// we have something to handle this class.
foreach ($this->dynamicInitializers as $initializerClass) {
@@ -465,7 +508,7 @@ private function initializerForClass(ClassReflector $target, string|UnitEnum|nul
return null;
}
- private function autowire(string $className, mixed ...$params): object
+ private function autowire(string $className, string|UnitEnum|null $tag, mixed ...$params): object
{
$classReflector = new ClassReflector($className);
@@ -490,7 +533,7 @@ private function autowire(string $className, mixed ...$params): object
&& ! $classReflector->getType()->matches(DynamicInitializer::class)
&& $classReflector->hasAttribute(Singleton::class)
) {
- $this->singleton($className, $instance);
+ $this->singleton($className, $instance, $tag);
}
foreach ($classReflector->getProperties() as $property) {
@@ -525,12 +568,16 @@ private function autowireDependencies(MethodReflector|FunctionReflector $method,
// Build the class by iterating through its
// dependencies and resolving them.
- foreach ($method->getParameters() as $parameter) {
- $dependencies[] = $this->clone()->autowireDependency(
- parameter: $parameter,
- tag: $parameter->getAttribute(Tag::class)?->name,
- providedValue: $parameters[$parameter->getName()] ?? null,
- );
+ try {
+ foreach ($method->getParameters() as $parameter) {
+ $dependencies[] = $this->clone()->autowireDependency(
+ parameter: $parameter,
+ tag: $parameter->getAttribute(Tag::class)?->name,
+ providedValue: $parameters[$parameter->getName()] ?? null,
+ );
+ }
+ } finally {
+ $this->resolveChain()->pop();
}
return $dependencies;
@@ -661,9 +708,12 @@ private function resolveChain(): DependencyChain
return $this->chain;
}
- private function stopChain(): void
+ /**
+ * Restores the parent resolution chain, or resets it to `null` at the top level.
+ */
+ private function stopChain(?DependencyChain $previous = null): void
{
- $this->chain = null;
+ $this->chain = $previous;
}
public function __clone(): void
diff --git a/packages/container/src/Singleton.php b/packages/container/src/Singleton.php
index caaaca6563..d732b06d04 100644
--- a/packages/container/src/Singleton.php
+++ b/packages/container/src/Singleton.php
@@ -11,5 +11,6 @@
{
public function __construct(
public ?string $tag = null,
+ public bool $dynamicTags = false,
) {}
}
diff --git a/packages/container/tests/ContainerTest.php b/packages/container/tests/ContainerTest.php
index 5e3b23b8c1..b37f7e8e6b 100644
--- a/packages/container/tests/ContainerTest.php
+++ b/packages/container/tests/ContainerTest.php
@@ -44,6 +44,10 @@
use Tempest\Container\Tests\Fixtures\DecoratorWithoutConstructor;
use Tempest\Container\Tests\Fixtures\DependencyWithBuiltinDependencies;
use Tempest\Container\Tests\Fixtures\DependencyWithTaggedDependency;
+use Tempest\Container\Tests\Fixtures\DynamicTaggedDependency;
+use Tempest\Container\Tests\Fixtures\DynamicTaggedDependencyDynamicInitializer;
+use Tempest\Container\Tests\Fixtures\DynamicTaggedDependencyInitializer;
+use Tempest\Container\Tests\Fixtures\DynamicTaggedDependencyWithInitializer;
use Tempest\Container\Tests\Fixtures\EnumTag;
use Tempest\Container\Tests\Fixtures\HasTagObject;
use Tempest\Container\Tests\Fixtures\ImplementsInterfaceA;
@@ -54,6 +58,7 @@
use Tempest\Container\Tests\Fixtures\InvokableClass;
use Tempest\Container\Tests\Fixtures\InvokableClassWithDependencies;
use Tempest\Container\Tests\Fixtures\InvokableClassWithParameters;
+use Tempest\Container\Tests\Fixtures\NonDynamicTaggedDependency;
use Tempest\Container\Tests\Fixtures\OptionalTypesClass;
use Tempest\Container\Tests\Fixtures\ResettableDependency;
use Tempest\Container\Tests\Fixtures\SingletonClass;
@@ -338,6 +343,76 @@ public function tagged_singleton_with_initializer(): void
$this->assertSame('cli', $container->get(TaggedDependency::class, 'cli')->name);
}
+ #[Test]
+ public function dynamic_tags_with_initializer(): void
+ {
+ $container = new GenericContainer();
+ $container->addInitializer(DynamicTaggedDependencyInitializer::class);
+
+ $a = $container->get(DynamicTaggedDependencyWithInitializer::class, tag: 'a');
+ $b = $container->get(DynamicTaggedDependencyWithInitializer::class, tag: 'b');
+
+ $this->assertNotSame($a, $b);
+
+ $this->assertSame($a, $container->get(DynamicTaggedDependencyWithInitializer::class, tag: 'a'));
+ $this->assertSame($b, $container->get(DynamicTaggedDependencyWithInitializer::class, tag: 'b'));
+ }
+
+ #[Test]
+ public function dynamic_tags_with_dynamic_initializer(): void
+ {
+ $container = new GenericContainer();
+ $container->addInitializer(DynamicTaggedDependencyDynamicInitializer::class);
+
+ $a = $container->get(DynamicTaggedDependencyWithInitializer::class, tag: 'a');
+ $b = $container->get(DynamicTaggedDependencyWithInitializer::class, tag: 'b');
+
+ $this->assertNotSame($a, $b);
+
+ $this->assertSame($a, $container->get(DynamicTaggedDependencyWithInitializer::class, tag: 'a'));
+ $this->assertSame($b, $container->get(DynamicTaggedDependencyWithInitializer::class, tag: 'b'));
+
+ $this->assertSame('a', $a->name);
+ $this->assertSame('b', $b->name);
+ }
+
+ #[Test]
+ public function dynamic_tags_with_singleton(): void
+ {
+ $container = new GenericContainer();
+
+ $a = $container->get(DynamicTaggedDependency::class, tag: 'a');
+ $b = $container->get(DynamicTaggedDependency::class, tag: 'b');
+
+ $this->assertNotSame($a, $b);
+
+ $this->assertSame($a, $container->get(DynamicTaggedDependency::class, tag: 'a'));
+ $this->assertSame($b, $container->get(DynamicTaggedDependency::class, tag: 'b'));
+ }
+
+ #[Test]
+ public function non_dynamic_tags_throw_exception(): void
+ {
+ $container = new GenericContainer();
+
+ $this->expectException(TaggedDependencyCouldNotBeResolved::class);
+
+ $container->get(NonDynamicTaggedDependency::class, tag: 'a');
+ }
+
+ #[Test]
+ public function dynamic_tags_preserve_the_untagged_singleton(): void
+ {
+ $container = new GenericContainer();
+
+ $default = $container->get(DynamicTaggedDependency::class);
+ $tagged = $container->get(DynamicTaggedDependency::class, tag: 'a');
+
+ $this->assertNotSame($default, $tagged);
+ $this->assertSame($tagged, $container->get(DynamicTaggedDependency::class, tag: 'a'));
+ $this->assertSame($default, $container->get(DynamicTaggedDependency::class));
+ }
+
#[Test]
public function tagged_singleton_exception(): void
{
diff --git a/packages/container/tests/Exceptions/CircularDependencyExceptionTest.php b/packages/container/tests/Exceptions/CircularDependencyExceptionTest.php
index 4b436656d1..1f0134192b 100644
--- a/packages/container/tests/Exceptions/CircularDependencyExceptionTest.php
+++ b/packages/container/tests/Exceptions/CircularDependencyExceptionTest.php
@@ -10,6 +10,11 @@
use Tempest\Container\GenericContainer;
use Tempest\Container\Tests\Fixtures\CircularA;
use Tempest\Container\Tests\Fixtures\CircularZ;
+use Tempest\Container\Tests\Fixtures\NestedResolutionA;
+use Tempest\Container\Tests\Fixtures\NestedResolutionAInitializer;
+use Tempest\Container\Tests\Fixtures\NestedResolutionBInitializer;
+use Tempest\Container\Tests\Fixtures\ResolvedTwiceParent;
+use Tempest\Container\Tests\Fixtures\ResolvedTwiceParentInitializer;
/**
* @internal
@@ -74,4 +79,27 @@ public function circular_dependency_as_a_child_test(): void
throw $circularDependencyException;
}
}
+
+ #[Test]
+ public function circular_dependency_after_a_nested_resolution_test(): void
+ {
+ $this->expectException(CircularDependencyEncountered::class);
+
+ $container = new GenericContainer();
+ $container->addInitializer(NestedResolutionAInitializer::class);
+ $container->addInitializer(NestedResolutionBInitializer::class);
+
+ $container->get(NestedResolutionA::class);
+ }
+
+ #[Test]
+ public function the_same_dependency_resolved_twice_in_one_chain_is_not_circular_test(): void
+ {
+ $container = new GenericContainer();
+ $container->addInitializer(ResolvedTwiceParentInitializer::class);
+
+ $parent = $container->get(ResolvedTwiceParent::class);
+
+ $this->assertInstanceOf(ResolvedTwiceParent::class, $parent);
+ }
}
diff --git a/packages/container/tests/Fixtures/DynamicTaggedDependency.php b/packages/container/tests/Fixtures/DynamicTaggedDependency.php
new file mode 100644
index 0000000000..22b9d80364
--- /dev/null
+++ b/packages/container/tests/Fixtures/DynamicTaggedDependency.php
@@ -0,0 +1,10 @@
+implements(DynamicTaggedDependencyWithInitializer::class);
+ }
+
+ #[Singleton(dynamicTags: true)]
+ public function initialize(ClassReflector $class, UnitEnum|string|null $tag, Container $container): DynamicTaggedDependencyWithInitializer
+ {
+ return new DynamicTaggedDependencyWithInitializer($tag);
+ }
+}
diff --git a/packages/container/tests/Fixtures/DynamicTaggedDependencyInitializer.php b/packages/container/tests/Fixtures/DynamicTaggedDependencyInitializer.php
new file mode 100644
index 0000000000..becfa5bab3
--- /dev/null
+++ b/packages/container/tests/Fixtures/DynamicTaggedDependencyInitializer.php
@@ -0,0 +1,18 @@
+get(Unrelated::class);
+ $container->get(NestedResolutionB::class);
+
+ return new NestedResolutionA();
+ }
+}
diff --git a/packages/container/tests/Fixtures/NestedResolutionB.php b/packages/container/tests/Fixtures/NestedResolutionB.php
new file mode 100644
index 0000000000..fd0cfef61b
--- /dev/null
+++ b/packages/container/tests/Fixtures/NestedResolutionB.php
@@ -0,0 +1,7 @@
+get(NestedResolutionA::class);
+
+ return new NestedResolutionB();
+ }
+}
diff --git a/packages/container/tests/Fixtures/NonDynamicTaggedDependency.php b/packages/container/tests/Fixtures/NonDynamicTaggedDependency.php
new file mode 100644
index 0000000000..9ab2988880
--- /dev/null
+++ b/packages/container/tests/Fixtures/NonDynamicTaggedDependency.php
@@ -0,0 +1,10 @@
+get(ResolvedTwiceLeaf::class),
+ $container->get(ResolvedTwiceLeaf::class),
+ );
+ }
+}
diff --git a/packages/container/tests/Fixtures/Unrelated.php b/packages/container/tests/Fixtures/Unrelated.php
new file mode 100644
index 0000000000..480e0d2242
--- /dev/null
+++ b/packages/container/tests/Fixtures/Unrelated.php
@@ -0,0 +1,7 @@
+clock->timestamp();
try {
$result = $callback($this);
@@ -37,7 +37,7 @@ public function invoke(callable $callback, Duration $duration): mixed
$exception = $thrown;
}
- $remainderInMicroseconds = intval($duration->getTotalMicroseconds() - ((microtime(true) - $start) * 1_000_000));
+ $remainderInMicroseconds = intval($duration->getTotalMicroseconds() - $this->clock->timestamp()->since($start)->getTotalMicroseconds());
if (! $this->canReturnEarly && $remainderInMicroseconds > 0) {
$this->clock->sleep(Duration::microseconds($remainderInMicroseconds));
diff --git a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php
index d665ec45e1..fc04f40d6c 100644
--- a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php
+++ b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php
@@ -26,7 +26,10 @@ final class CountQueryBuilder implements BuildsQuery, SupportsWhereStatements
{
use HasConditions;
use OnDatabase;
+
+ /** @use HasWhereQueryBuilderMethods */
use HasWhereQueryBuilderMethods;
+
use TransformsQueryBuilder;
private CountStatement $count;
diff --git a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php
index 49a29ee4ab..6103c39dca 100644
--- a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php
+++ b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php
@@ -22,7 +22,10 @@ final class DeleteQueryBuilder implements BuildsQuery, SupportsWhereStatements
{
use HasConditions;
use OnDatabase;
+
+ /** @use HasWhereQueryBuilderMethods */
use HasWhereQueryBuilderMethods;
+
use TransformsQueryBuilder;
private DeleteStatement $delete;
diff --git a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php
index 77510d5081..deff32f9a5 100644
--- a/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php
+++ b/packages/database/src/Builder/QueryBuilders/HasWhereQueryBuilderMethods.php
@@ -10,9 +10,14 @@
use function Tempest\Support\str;
+/**
+ * @template TModel
+ */
trait HasWhereQueryBuilderMethods
{
use HasConvenientWhereMethods;
+
+ /** @use HasWhereRelationMethods */
use HasWhereRelationMethods;
/**
diff --git a/packages/database/src/Builder/QueryBuilders/HasWhereRelationMethods.php b/packages/database/src/Builder/QueryBuilders/HasWhereRelationMethods.php
index 83ed7acc4f..23d9856d79 100644
--- a/packages/database/src/Builder/QueryBuilders/HasWhereRelationMethods.php
+++ b/packages/database/src/Builder/QueryBuilders/HasWhereRelationMethods.php
@@ -13,6 +13,9 @@
use function Tempest\Support\arr;
use function Tempest\Support\str;
+/**
+ * @template TModel
+ */
trait HasWhereRelationMethods
{
/**
diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php
index 1313de3cd0..42223df978 100644
--- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php
+++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php
@@ -410,6 +410,12 @@ private function resolveIterableData(iterable $model): array
$entry[$key] = $this->serializeIterableValue($key, $value);
}
+ $primaryKey = $this->model->getPrimaryKey();
+
+ if ($primaryKey !== null && $this->model->hasUuidPrimaryKey() && ! isset($entry[$primaryKey])) {
+ return [$primaryKey => Random\uuid(), ...$entry];
+ }
+
return $entry;
}
diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php
index 496da87eb8..ed34c9162d 100644
--- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php
+++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php
@@ -49,7 +49,10 @@ final class SelectQueryBuilder implements BuildsQuery, SupportsWhereStatements,
{
use HasConditions;
use OnDatabase;
+
+ /** @use HasWhereQueryBuilderMethods */
use HasWhereQueryBuilderMethods;
+
use TransformsQueryBuilder;
public ModelInspector $model;
@@ -257,6 +260,10 @@ public function orderBy(string $field, Direction $direction = Direction::ASC): s
return $this->orderByRaw($field);
}
+ if (! str_contains($field, '.')) {
+ $field = $this->model->getTableName() . '.' . $field;
+ }
+
$this->select->orderBy[] = new OrderByStatement(field: $field, direction: $direction);
return $this;
diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php
index 8557b54c54..77f956e520 100644
--- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php
+++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php
@@ -44,7 +44,10 @@ final class UpdateQueryBuilder implements BuildsQuery, SupportsWhereStatements
{
use HasConditions;
use OnDatabase;
+
+ /** @use HasWhereQueryBuilderMethods */
use HasWhereQueryBuilderMethods;
+
use TransformsQueryBuilder;
private UpdateStatement $update;
diff --git a/packages/database/src/Config/DatabaseConnectionString.php b/packages/database/src/Config/DatabaseConnectionString.php
new file mode 100644
index 0000000000..4dc2ee48fb
--- /dev/null
+++ b/packages/database/src/Config/DatabaseConnectionString.php
@@ -0,0 +1,72 @@
+ ltrim($this->path ?? '', '/') ?: null;
+ }
+
+ /**
+ * @param string $scheme The scheme, such as `mysql` or `postgres`.
+ * @param null|string $host The host, or the path to a Unix socket.
+ * @param null|int $port The port.
+ * @param null|string $username The username.
+ * @param null|string $password The password.
+ * @param null|string $path The path following the authority, such as `/database` or `/path/to/database.sqlite`.
+ * @param array $parameters The query parameters, such as `sslmode` or `connect_timeout`.
+ */
+ public function __construct(
+ public readonly string $scheme,
+ public readonly ?string $host = null,
+ public readonly ?int $port = null,
+ public readonly ?string $username = null,
+ #[SensitiveParameter]
+ public readonly ?string $password = null,
+ public readonly ?string $path = null,
+ public readonly array $parameters = [],
+ ) {}
+
+ /**
+ * Creates a DatabaseConnectionString instance from a connection string, such as `postgres://user:password@host:5432/database?sslmode=require`. Components are percent-decoded, so a Unix socket may be given as a percent-encoded host.
+ */
+ public static function from(#[SensitiveParameter] string $connectionString): self
+ {
+ $uri = Uri::parse($connectionString);
+
+ if ($uri?->getScheme() === null) {
+ throw DatabaseConnectionStringWasInvalid::couldNotBeParsed();
+ }
+
+ parse_str($uri->getQuery() ?? '', $parameters);
+
+ return new self(
+ scheme: $uri->getScheme(),
+ host: self::decode($uri->getHost()),
+ port: $uri->getPort(),
+ username: self::decode($uri->getUsername()),
+ password: self::decode($uri->getPassword()),
+ path: self::decode($uri->getPath()),
+ parameters: array_filter($parameters, is_string(...)),
+ );
+ }
+
+ private static function decode(?string $component): ?string
+ {
+ if ($component === null || $component === '') {
+ return null;
+ }
+
+ return rawurldecode($component);
+ }
+}
diff --git a/packages/database/src/Config/MysqlConfig.php b/packages/database/src/Config/MysqlConfig.php
index 56a6b6724e..e9136a937c 100644
--- a/packages/database/src/Config/MysqlConfig.php
+++ b/packages/database/src/Config/MysqlConfig.php
@@ -7,6 +7,7 @@
use PDO;
use Pdo\Mysql;
use SensitiveParameter;
+use Tempest\Database\Exceptions\DatabaseConnectionStringWasInvalid;
use Tempest\Database\Migrations\DatePrefixStrategy;
use Tempest\Database\Migrations\MigrationNamingStrategy;
use Tempest\Database\Tables\NamingStrategy;
@@ -17,10 +18,15 @@ final class MysqlConfig implements DatabaseConfig
{
public string $dsn {
get => sprintf(
- 'mysql:host=%s:%s;dbname=%s',
+ 'mysql:host=%s:%s;dbname=%s%s',
$this->host,
$this->port,
$this->database,
+ implode('', array_map(
+ fn (string $key, string $value) => ";{$key}={$value}",
+ array_keys($this->parameters),
+ $this->parameters,
+ )),
);
}
@@ -78,6 +84,7 @@ final class MysqlConfig implements DatabaseConfig
* @param NamingStrategy $namingStrategy The naming strategy for database tables and columns.
* @param MigrationNamingStrategy $migrationNaming The naming strategy for migration file prefixes.
* @param string|UnitEnum|null $tag An optional tag to identify this database configuration.
+ * @param array $parameters Additional parameters appended to the DSN, such as `unix_socket` or `charset`.
*/
public function __construct(
#[SensitiveParameter]
@@ -98,5 +105,47 @@ public function __construct(
public NamingStrategy $namingStrategy = new PluralizedSnakeCaseStrategy(),
public MigrationNamingStrategy $migrationNaming = new DatePrefixStrategy(),
public string|UnitEnum|null $tag = null,
+ public array $parameters = [],
) {}
+
+ /**
+ * Creates a MySQL configuration from a connection string, such as `mysql://user:password@host:3306/database?charset=utf8mb4`. Components missing from the connection string fall back to their defaults, and query parameters are appended to the DSN. A Unix socket may be given as a percent-encoded host or as the `unix_socket` query parameter.
+ *
+ * @param string $connectionString The connection string. Supported schemes are `mysql` and `mariadb`.
+ * @param mixed ...$options Named constructor arguments, which take precedence over the values parsed from the connection string.
+ */
+ public static function fromConnectionString(#[SensitiveParameter] string $connectionString, mixed ...$options): self
+ {
+ $parsed = DatabaseConnectionString::from($connectionString);
+
+ if (! in_array($parsed->scheme, ['mysql', 'mariadb'], strict: true)) {
+ throw DatabaseConnectionStringWasInvalid::schemeWasNotSupported($parsed->scheme, ['mysql', 'mariadb']);
+ }
+
+ $host = $parsed->host;
+ $parameters = $parsed->parameters;
+
+ if ($host !== null && str_starts_with($host, '/')) {
+ $parameters['unix_socket'] ??= $host;
+ $host = null;
+ }
+
+ if ($host === null && ! isset($parameters['unix_socket'])) {
+ throw DatabaseConnectionStringWasInvalid::hostWasMissing();
+ }
+
+ $arguments = array_filter(
+ [
+ 'host' => $host,
+ 'port' => $parsed->port !== null ? (string) $parsed->port : null,
+ 'username' => $parsed->username,
+ 'password' => $parsed->password,
+ 'database' => $parsed->database,
+ 'parameters' => $parameters,
+ ],
+ fn (mixed $value) => $value !== null,
+ );
+
+ return new self(...[...$arguments, ...$options]);
+ }
}
diff --git a/packages/database/src/Config/PostgresConfig.php b/packages/database/src/Config/PostgresConfig.php
index 7112e80061..7012600683 100644
--- a/packages/database/src/Config/PostgresConfig.php
+++ b/packages/database/src/Config/PostgresConfig.php
@@ -6,6 +6,7 @@
use PDO;
use SensitiveParameter;
+use Tempest\Database\Exceptions\DatabaseConnectionStringWasInvalid;
use Tempest\Database\Migrations\DatePrefixStrategy;
use Tempest\Database\Migrations\MigrationNamingStrategy;
use Tempest\Database\Tables\NamingStrategy;
@@ -16,12 +17,17 @@ final class PostgresConfig implements DatabaseConfig
{
public string $dsn {
get => sprintf(
- 'pgsql:host=%s;port=%s;dbname=%s;user=%s;password=%s',
+ 'pgsql:host=%s;port=%s;dbname=%s;user=%s;password=%s%s',
$this->host,
$this->port,
$this->database,
$this->username,
$this->password,
+ implode('', array_map(
+ fn (string $key, string $value) => ";{$key}={$value}",
+ array_keys($this->parameters),
+ $this->parameters,
+ )),
);
}
@@ -50,7 +56,7 @@ final class PostgresConfig implements DatabaseConfig
}
/**
- * @param string $host The PostgreSQL server hostname or IP address.
+ * @param string $host The PostgreSQL server hostname or IP address, or the directory containing its Unix socket.
* @param string $port The PostgreSQL server port number.
* @param string $username The PostgreSQL username for authentication.
* @param string $password The PostgreSQL password for authentication.
@@ -59,6 +65,7 @@ final class PostgresConfig implements DatabaseConfig
* @param NamingStrategy $namingStrategy The naming strategy for database tables and columns.
* @param MigrationNamingStrategy $migrationNaming The naming strategy for migration file prefixes.
* @param string|UnitEnum|null $tag An optional tag to identify this database configuration.
+ * @param array $parameters Additional parameters appended to the DSN, such as `sslmode`, `connect_timeout` or `keepalives`.
*/
public function __construct(
#[SensitiveParameter]
@@ -75,5 +82,41 @@ public function __construct(
public NamingStrategy $namingStrategy = new PluralizedSnakeCaseStrategy(),
public MigrationNamingStrategy $migrationNaming = new DatePrefixStrategy(),
public string|UnitEnum|null $tag = null,
+ public array $parameters = [],
) {}
+
+ /**
+ * Creates a PostgreSQL configuration from a connection string, such as `postgres://user:password@host:5432/database?sslmode=require`. Components missing from the connection string fall back to their defaults, and query parameters are appended to the DSN. As with libpq, the `host`, `port`, `user`, `password` and `dbname` query parameters take precedence over the other components, so a Unix socket may be given as `postgres:///database?host=/var/run/postgresql` or as a percent-encoded host.
+ *
+ * @param string $connectionString The connection string. Supported schemes are `postgres`, `postgresql` and `pgsql`.
+ * @param mixed ...$options Named constructor arguments, which take precedence over the values parsed from the connection string.
+ */
+ public static function fromConnectionString(#[SensitiveParameter] string $connectionString, mixed ...$options): self
+ {
+ $parsed = DatabaseConnectionString::from($connectionString);
+
+ if (! in_array($parsed->scheme, ['postgres', 'postgresql', 'pgsql'], strict: true)) {
+ throw DatabaseConnectionStringWasInvalid::schemeWasNotSupported($parsed->scheme, ['postgres', 'postgresql', 'pgsql']);
+ }
+
+ $parameters = $parsed->parameters;
+
+ $arguments = [
+ 'host' => $parameters['host'] ?? $parsed->host,
+ 'port' => $parameters['port'] ?? ($parsed->port !== null ? (string) $parsed->port : null),
+ 'username' => $parameters['user'] ?? $parsed->username,
+ 'password' => $parameters['password'] ?? $parsed->password,
+ 'database' => $parameters['dbname'] ?? $parsed->database,
+ 'parameters' => array_diff_key($parameters, array_flip(['host', 'port', 'user', 'password', 'dbname'])),
+ ];
+
+ if ($arguments['host'] === null) {
+ throw DatabaseConnectionStringWasInvalid::hostWasMissing();
+ }
+
+ return new self(...[
+ ...array_filter($arguments, fn (mixed $value) => $value !== null),
+ ...$options,
+ ]);
+ }
}
diff --git a/packages/database/src/Config/SQLiteConfig.php b/packages/database/src/Config/SQLiteConfig.php
index f85d9ed115..b2a074965e 100644
--- a/packages/database/src/Config/SQLiteConfig.php
+++ b/packages/database/src/Config/SQLiteConfig.php
@@ -6,6 +6,7 @@
use PDO;
use SensitiveParameter;
+use Tempest\Database\Exceptions\DatabaseConnectionStringWasInvalid;
use Tempest\Database\Migrations\DatePrefixStrategy;
use Tempest\Database\Migrations\MigrationNamingStrategy;
use Tempest\Database\Tables\NamingStrategy;
@@ -68,4 +69,28 @@ public function __construct(
public MigrationNamingStrategy $migrationNaming = new DatePrefixStrategy(),
public string|UnitEnum|null $tag = null,
) {}
+
+ /**
+ * Creates a SQLite configuration from a connection string, such as `sqlite:///path/to/database.sqlite` or `sqlite::memory:`.
+ *
+ * @param string $connectionString The connection string, using the `sqlite` scheme.
+ * @param mixed ...$options Named constructor arguments, which take precedence over the values parsed from the connection string.
+ */
+ public static function fromConnectionString(#[SensitiveParameter] string $connectionString, mixed ...$options): self
+ {
+ $parsed = DatabaseConnectionString::from($connectionString);
+
+ if ($parsed->scheme !== 'sqlite') {
+ throw DatabaseConnectionStringWasInvalid::schemeWasNotSupported($parsed->scheme, ['sqlite']);
+ }
+
+ if ($parsed->path === null) {
+ throw DatabaseConnectionStringWasInvalid::couldNotBeParsed();
+ }
+
+ return new self(...[
+ 'path' => $parsed->path,
+ ...$options,
+ ]);
+ }
}
diff --git a/packages/database/src/Exceptions/DatabaseConnectionStringWasInvalid.php b/packages/database/src/Exceptions/DatabaseConnectionStringWasInvalid.php
new file mode 100644
index 0000000000..8bbee17d28
--- /dev/null
+++ b/packages/database/src/Exceptions/DatabaseConnectionStringWasInvalid.php
@@ -0,0 +1,29 @@
+relationDataIsEmpty($item)) {
- return false;
- }
- }
-
- return true;
+ return array_all($value, $this->relationDataIsEmpty(...));
}
public function normalizeRow(ModelInspector $model, array $row, MutableArray $data): array
diff --git a/packages/database/src/Query.php b/packages/database/src/Query.php
index 6cf14d7dfc..2302fd161c 100644
--- a/packages/database/src/Query.php
+++ b/packages/database/src/Query.php
@@ -5,6 +5,8 @@
namespace Tempest\Database;
use Tempest\Database\Config\DatabaseDialect;
+use Tempest\Database\QueryStatements\InsertStatement;
+use Tempest\Support\Arr\ImmutableArray;
use Tempest\Support\Str\ImmutableString;
use function Tempest\Container\get;
@@ -48,11 +50,50 @@ public function execute(mixed ...$bindings): ?PrimaryKey
return null;
}
- return isset($query->bindings[$this->primaryKeyColumn])
- ? new PrimaryKey($query->bindings[$this->primaryKeyColumn])
+ if (isset($query->bindings[$this->primaryKeyColumn])) {
+ return new PrimaryKey($query->bindings[$this->primaryKeyColumn]);
+ }
+
+ // Insert bindings are positional; resolve the primary key through its column position.
+ $positionalValue = $this->resolvePositionalPrimaryKeyBinding($query);
+
+ return $positionalValue !== null
+ ? new PrimaryKey($positionalValue)
: $database->getLastInsertId();
}
+ private function resolvePositionalPrimaryKeyBinding(Query $query): mixed
+ {
+ if (! $this->sql instanceof InsertStatement) {
+ return null;
+ }
+
+ $firstEntry = $this->sql->entries->first();
+
+ if ($firstEntry instanceof ImmutableArray) {
+ $firstEntry = $firstEntry->toArray();
+ }
+
+ if (! is_array($firstEntry)) {
+ return null;
+ }
+
+ $index = array_search($this->primaryKeyColumn, array_keys($firstEntry), strict: true);
+
+ if ($index === false) {
+ return null;
+ }
+
+ $value = $query->bindings[$index] ?? null;
+
+ // 0 is treated as "auto-increment" by MySQL/PostgreSQL — not a real id.
+ if ($value === 0 || $value === null) {
+ return null;
+ }
+
+ return $value;
+ }
+
public function fetch(mixed ...$bindings): array
{
return $this->database->fetch($this->withBindings($bindings));
diff --git a/packages/database/src/QueryStatements/CreateTableStatement.php b/packages/database/src/QueryStatements/CreateTableStatement.php
index ab883e3646..9f341abd81 100644
--- a/packages/database/src/QueryStatements/CreateTableStatement.php
+++ b/packages/database/src/QueryStatements/CreateTableStatement.php
@@ -37,13 +37,14 @@ public static function forModel(string $modelClass): self
*
* By default, MySQL and SQLite use an auto-incrementing `INTEGER` column, and PostgreSQL uses `SERIAL`.
* When setting `uuid` to `true`, MySQL will use `VARCHAR(36)`, PostgreSQL will use `UUID`, and SQLite will use `TEXT`.
+ * When setting `unsigned` to `true`, MySQL will use `INTEGER UNSIGNED`. This has no effect on PostgreSQL and SQLite, or when `uuid` is `true`.
*/
- public function primary(string $name = 'id', bool $uuid = false): self
+ public function primary(string $name = 'id', bool $uuid = false, bool $unsigned = false): self
{
if ($uuid) {
$this->uuid($name);
} else {
- $this->statements[] = new PrimaryKeyStatement($name);
+ $this->statements[] = new PrimaryKeyStatement($name, $unsigned);
}
return $this;
@@ -69,12 +70,19 @@ public function uuid(string $name = 'id'): self
*
* @param string $local The local column in the format `this_table.foreign_id`.
* @param string $foreign The foreign column in the format `other_table.id`.
+ * @param bool $unsigned Whether the local column is unsigned. Must match the referenced primary key on MySQL.
*/
- public function belongsTo(string $local, string $foreign, OnDelete $onDelete = OnDelete::RESTRICT, OnUpdate $onUpdate = OnUpdate::NO_ACTION, bool $nullable = false): self
- {
+ public function belongsTo(
+ string $local,
+ string $foreign,
+ OnDelete $onDelete = OnDelete::RESTRICT,
+ OnUpdate $onUpdate = OnUpdate::NO_ACTION,
+ bool $nullable = false,
+ bool $unsigned = false,
+ ): self {
[, $localKey] = explode('.', $local);
- $this->integer($localKey, nullable: $nullable);
+ $this->integer($localKey, unsigned: $unsigned, nullable: $nullable);
$this->statements[] = new BelongsToStatement(
local: $local,
@@ -101,9 +109,77 @@ public function belongsTo(string $local, string $foreign, OnDelete $onDelete = O
*
* @param string $local The local column in the format `[this_table.]foreign_id`.
* @param string $constrainedOn The foreign table in the format `other_table[.id]`.
+ * @param bool $unsigned Whether the local column is unsigned. Must match the referenced primary key on MySQL.
*/
- public function foreignId(string $local, string $constrainedOn, OnDelete $onDelete = OnDelete::RESTRICT, OnUpdate $onUpdate = OnUpdate::NO_ACTION, bool $nullable = false): self
+ public function foreignId(
+ string $local,
+ string $constrainedOn,
+ OnDelete $onDelete = OnDelete::RESTRICT,
+ OnUpdate $onUpdate = OnUpdate::NO_ACTION,
+ bool $nullable = false,
+ bool $unsigned = false,
+ ): self {
+ if (! str_contains($local, '.')) {
+ $local = $this->tableName . '.' . $local;
+ }
+
+ if (! str_contains($constrainedOn, '.')) {
+ $constrainedOn .= '.id';
+ }
+
+ return $this->belongsTo($local, $constrainedOn, $onDelete, $onUpdate, $nullable, $unsigned);
+ }
+
+ /**
+ * Adds a UUID column with a foreign key relationship to another table. This is an alias to `foreignUuid`.
+ *
+ * **Example**
+ * ```php
+ * $table->belongsToUuid('orders.customer_uuid', 'customers.uuid');
+ * ```
+ *
+ * @param string $local The local column in the format `this_table.foreign_uuid`.
+ * @param string $foreign The foreign column in the format `other_table.uuid`.
+ */
+ public function belongsToUuid(string $local, string $foreign, OnDelete $onDelete = OnDelete::RESTRICT, OnUpdate $onUpdate = OnUpdate::NO_ACTION, bool $nullable = false): self
{
+ [, $localKey] = explode('.', $local);
+
+ $this->statements[] = new UuidStatement($localKey, nullable: $nullable);
+
+ $this->statements[] = new BelongsToStatement(
+ local: $local,
+ foreign: $foreign,
+ onDelete: $onDelete,
+ onUpdate: $onUpdate,
+ );
+
+ return $this;
+ }
+
+ /**
+ * Adds a UUID column with a foreign key relationship to another table.
+ *
+ * **Example**
+ * ```php
+ * new CreateTableStatement('orders')
+ * ->foreignUuid('customer_uuid', constrainedOn: 'customers');
+ * ```
+ * ```php
+ * new CreateTableStatement('orders')
+ * ->foreignUuid('orders.customer_uuid', constrainedOn: 'customers.uuid');
+ * ```
+ *
+ * @param string $local The local column in the format `[this_table.]foreign_uuid`.
+ * @param string $constrainedOn The foreign table in the format `other_table[.uuid]`.
+ */
+ public function foreignUuid(
+ string $local,
+ string $constrainedOn,
+ OnDelete $onDelete = OnDelete::RESTRICT,
+ OnUpdate $onUpdate = OnUpdate::NO_ACTION,
+ bool $nullable = false,
+ ): self {
if (! str_contains($local, '.')) {
$local = $this->tableName . '.' . $local;
}
@@ -112,7 +188,7 @@ public function foreignId(string $local, string $constrainedOn, OnDelete $onDele
$constrainedOn .= '.id';
}
- return $this->belongsTo($local, $constrainedOn, $onDelete, $onUpdate, $nullable);
+ return $this->belongsToUuid($local, $constrainedOn, $onDelete, $onUpdate, $nullable);
}
/**
diff --git a/packages/database/src/QueryStatements/IntegerStatement.php b/packages/database/src/QueryStatements/IntegerStatement.php
index 9a274ee290..6358b058bd 100644
--- a/packages/database/src/QueryStatements/IntegerStatement.php
+++ b/packages/database/src/QueryStatements/IntegerStatement.php
@@ -21,6 +21,10 @@ public function compile(DatabaseDialect $dialect): string
{
$name = $dialect->quoteIdentifier($this->name);
+ $type = is_int($this->size)
+ ? DatabaseIntegerSize::fromBytes($this->size)->toString()
+ : $this->size->toString();
+
return match ($dialect) {
DatabaseDialect::SQLITE => sprintf(
'%s INTEGER %s %s %s',
@@ -29,10 +33,18 @@ public function compile(DatabaseDialect $dialect): string
$this->default !== null ? "DEFAULT {$this->default}" : '',
$this->nullable ? '' : 'NOT NULL',
),
- default => sprintf(
+ // Postgres has no unsigned integer type, so omit the keyword there.
+ DatabaseDialect::POSTGRESQL => sprintf(
+ '%s %s %s %s',
+ $name,
+ $type,
+ $this->default !== null ? "DEFAULT {$this->default}" : '',
+ $this->nullable ? '' : 'NOT NULL',
+ ),
+ DatabaseDialect::MYSQL => sprintf(
'%s %s %s %s %s',
$name,
- is_int($this->size) ? DatabaseIntegerSize::fromBytes($this->size)->toString() : $this->size->toString(),
+ $type,
$this->unsigned ? 'UNSIGNED' : '',
$this->default !== null ? "DEFAULT {$this->default}" : '',
$this->nullable ? '' : 'NOT NULL',
diff --git a/packages/database/src/QueryStatements/PrimaryKeyStatement.php b/packages/database/src/QueryStatements/PrimaryKeyStatement.php
index 528cdc6fac..530542a609 100644
--- a/packages/database/src/QueryStatements/PrimaryKeyStatement.php
+++ b/packages/database/src/QueryStatements/PrimaryKeyStatement.php
@@ -11,6 +11,7 @@
{
public function __construct(
private string $name = 'id',
+ private bool $unsigned = false,
) {}
public function compile(DatabaseDialect $dialect): string
@@ -18,7 +19,10 @@ public function compile(DatabaseDialect $dialect): string
$name = $dialect->quoteIdentifier($this->name);
return match ($dialect) {
- DatabaseDialect::MYSQL => "{$name} INTEGER PRIMARY KEY AUTO_INCREMENT",
+ DatabaseDialect::MYSQL => $this->unsigned
+ ? "{$name} INTEGER UNSIGNED PRIMARY KEY AUTO_INCREMENT"
+ : "{$name} INTEGER PRIMARY KEY AUTO_INCREMENT",
+ // Postgres and SQLite have no unsigned integer type, so omit the keyword there.
DatabaseDialect::POSTGRESQL => "{$name} SERIAL PRIMARY KEY",
DatabaseDialect::SQLITE => "{$name} INTEGER PRIMARY KEY AUTOINCREMENT",
};
diff --git a/packages/database/src/QueryStatements/UuidStatement.php b/packages/database/src/QueryStatements/UuidStatement.php
new file mode 100644
index 0000000000..8e80b64b5e
--- /dev/null
+++ b/packages/database/src/QueryStatements/UuidStatement.php
@@ -0,0 +1,31 @@
+quoteIdentifier($this->name);
+ $nullable = $this->nullable ? '' : ' NOT NULL';
+
+ return match ($dialect) {
+ DatabaseDialect::MYSQL => "{$name} CHAR(36){$nullable}",
+ DatabaseDialect::POSTGRESQL => "{$name} UUID{$nullable}",
+ DatabaseDialect::SQLITE => "{$name} TEXT{$nullable}",
+ };
+ }
+}
diff --git a/packages/database/tests/Config/DatabaseConfigTest.php b/packages/database/tests/Config/DatabaseConfigTest.php
index ad7e54770a..e8d6044a7f 100644
--- a/packages/database/tests/Config/DatabaseConfigTest.php
+++ b/packages/database/tests/Config/DatabaseConfigTest.php
@@ -14,6 +14,7 @@
use Tempest\Database\Config\MysqlConfig;
use Tempest\Database\Config\PostgresConfig;
use Tempest\Database\Config\SQLiteConfig;
+use Tempest\Database\Exceptions\DatabaseConnectionStringWasInvalid;
/**
* @internal
@@ -118,4 +119,184 @@ public static function provide_database_drivers_with_options(): Generator
],
];
}
+
+ #[DataProvider('provide_connection_strings')]
+ #[Test]
+ public function driver_can_be_created_from_connection_string(DatabaseConfig $driver, string $dsn, ?string $username, ?string $password): void
+ {
+ $this->assertSame($dsn, $driver->dsn);
+ $this->assertSame($username, $driver->username);
+ $this->assertSame($password, $driver->password);
+ }
+
+ public static function provide_connection_strings(): Generator
+ {
+ yield 'mysql' => [
+ MysqlConfig::fromConnectionString('mysql://user:secret@db.example.com:3307/tempest'),
+ 'mysql:host=db.example.com:3307;dbname=tempest',
+ 'user',
+ 'secret',
+ ];
+
+ yield 'mariadb' => [
+ MysqlConfig::fromConnectionString('mariadb://user:secret@db.example.com:3307/tempest'),
+ 'mysql:host=db.example.com:3307;dbname=tempest',
+ 'user',
+ 'secret',
+ ];
+
+ yield 'mysql with encoded credentials' => [
+ MysqlConfig::fromConnectionString('mysql://us%3Aer:p%40ss%2Fw%3Ard@db.example.com/tempest'),
+ 'mysql:host=db.example.com:3306;dbname=tempest',
+ 'us:er',
+ 'p@ss/w:rd',
+ ];
+
+ yield 'mysql with defaults' => [
+ MysqlConfig::fromConnectionString('mysql://db.example.com'),
+ 'mysql:host=db.example.com:3306;dbname=app',
+ 'root',
+ '',
+ ];
+
+ yield 'mysql with overrides' => [
+ MysqlConfig::fromConnectionString('mysql://user:secret@db.example.com:3307/tempest', database: 'other', username: 'admin'),
+ 'mysql:host=db.example.com:3307;dbname=other',
+ 'admin',
+ 'secret',
+ ];
+
+ yield 'mysql with query parameters' => [
+ MysqlConfig::fromConnectionString('mysql://user:secret@db.example.com/tempest?charset=utf8mb4'),
+ 'mysql:host=db.example.com:3306;dbname=tempest;charset=utf8mb4',
+ 'user',
+ 'secret',
+ ];
+
+ yield 'mysql with unix socket parameter' => [
+ MysqlConfig::fromConnectionString('mysql://user:secret@localhost/tempest?unix_socket=/var/run/mysqld/mysqld.sock'),
+ 'mysql:host=localhost:3306;dbname=tempest;unix_socket=/var/run/mysqld/mysqld.sock',
+ 'user',
+ 'secret',
+ ];
+
+ yield 'mysql with unix socket host' => [
+ MysqlConfig::fromConnectionString('mysql://user:secret@%2Fvar%2Frun%2Fmysqld%2Fmysqld.sock/tempest'),
+ 'mysql:host=localhost:3306;dbname=tempest;unix_socket=/var/run/mysqld/mysqld.sock',
+ 'user',
+ 'secret',
+ ];
+
+ yield 'mysql with unix socket and without host' => [
+ MysqlConfig::fromConnectionString('mysql://user:secret@/tempest?unix_socket=/var/run/mysqld/mysqld.sock'),
+ 'mysql:host=localhost:3306;dbname=tempest;unix_socket=/var/run/mysqld/mysqld.sock',
+ 'user',
+ 'secret',
+ ];
+
+ yield 'postgres' => [
+ PostgresConfig::fromConnectionString('postgres://user:secret@db.example.com:5433/tempest'),
+ 'pgsql:host=db.example.com;port=5433;dbname=tempest;user=user;password=secret',
+ 'user',
+ 'secret',
+ ];
+
+ yield 'postgresql' => [
+ PostgresConfig::fromConnectionString('postgresql://user:secret@db.example.com/tempest'),
+ 'pgsql:host=db.example.com;port=5432;dbname=tempest;user=user;password=secret',
+ 'user',
+ 'secret',
+ ];
+
+ yield 'pgsql' => [
+ PostgresConfig::fromConnectionString('pgsql://db.example.com'),
+ 'pgsql:host=db.example.com;port=5432;dbname=app;user=postgres;password=',
+ 'postgres',
+ '',
+ ];
+
+ yield 'postgres with query parameters' => [
+ PostgresConfig::fromConnectionString('postgres://user:secret@db.example.com/tempest?sslmode=require&connect_timeout=10&keepalives=1'),
+ 'pgsql:host=db.example.com;port=5432;dbname=tempest;user=user;password=secret;sslmode=require;connect_timeout=10;keepalives=1',
+ 'user',
+ 'secret',
+ ];
+
+ yield 'postgres with unix socket parameter' => [
+ PostgresConfig::fromConnectionString('postgresql://user:secret@/tempest?host=/var/run/postgresql'),
+ 'pgsql:host=/var/run/postgresql;port=5432;dbname=tempest;user=user;password=secret',
+ 'user',
+ 'secret',
+ ];
+
+ yield 'postgres with unix socket host' => [
+ PostgresConfig::fromConnectionString('postgresql://user:secret@%2Fvar%2Frun%2Fpostgresql/tempest'),
+ 'pgsql:host=/var/run/postgresql;port=5432;dbname=tempest;user=user;password=secret',
+ 'user',
+ 'secret',
+ ];
+
+ yield 'postgres with connection parameters' => [
+ PostgresConfig::fromConnectionString('postgresql:///?host=db.example.com&port=5433&user=user&password=secret&dbname=tempest&sslmode=verify-full'),
+ 'pgsql:host=db.example.com;port=5433;dbname=tempest;user=user;password=secret;sslmode=verify-full',
+ 'user',
+ 'secret',
+ ];
+
+ yield 'sqlite with absolute path' => [
+ SQLiteConfig::fromConnectionString('sqlite:///usr/local/db.sqlite'),
+ 'sqlite:/usr/local/db.sqlite',
+ null,
+ null,
+ ];
+
+ yield 'sqlite in memory' => [
+ SQLiteConfig::fromConnectionString('sqlite::memory:'),
+ 'sqlite::memory:',
+ null,
+ null,
+ ];
+ }
+
+ #[Test]
+ public function connection_string_options_are_passed_to_the_driver(): void
+ {
+ $config = MysqlConfig::fromConnectionString(
+ 'mysql://user:secret@db.example.com/tempest',
+ persistent: true,
+ certificateAuthority: '/etc/ssl/certs/ca-certificates.crt',
+ tag: 'main',
+ );
+
+ $this->assertSame('main', $config->tag);
+ $this->assertSame(
+ [
+ PDO::ATTR_PERSISTENT => true,
+ Mysql::ATTR_SSL_CA => '/etc/ssl/certs/ca-certificates.crt',
+ ],
+ $config->options,
+ );
+ }
+
+ #[DataProvider('provide_invalid_connection_strings')]
+ #[Test]
+ public function invalid_connection_string_throws(callable $factory): void
+ {
+ $this->expectException(DatabaseConnectionStringWasInvalid::class);
+
+ $factory();
+ }
+
+ public static function provide_invalid_connection_strings(): Generator
+ {
+ yield 'mysql with postgres scheme' => [fn () => MysqlConfig::fromConnectionString('postgres://user:secret@db.example.com/tempest')];
+ yield 'postgres with mysql scheme' => [fn () => PostgresConfig::fromConnectionString('mysql://user:secret@db.example.com/tempest')];
+ yield 'mysql without host' => [fn () => MysqlConfig::fromConnectionString('mysql:///tempest')];
+ yield 'postgres without host' => [fn () => PostgresConfig::fromConnectionString('postgres:///tempest')];
+ yield 'mysql without scheme' => [fn () => MysqlConfig::fromConnectionString('db.example.com')];
+ yield 'mysql with invalid port' => [fn () => MysqlConfig::fromConnectionString('mysql://db.example.com:port/tempest')];
+ yield 'empty connection string' => [fn () => MysqlConfig::fromConnectionString('')];
+ yield 'sqlite with mysql scheme' => [fn () => SQLiteConfig::fromConnectionString('mysql://db.example.com/tempest')];
+ yield 'sqlite without path' => [fn () => SQLiteConfig::fromConnectionString('sqlite://')];
+ }
}
diff --git a/packages/database/tests/Config/DatabaseConnectionStringTest.php b/packages/database/tests/Config/DatabaseConnectionStringTest.php
new file mode 100644
index 0000000000..5b9c3ff92f
--- /dev/null
+++ b/packages/database/tests/Config/DatabaseConnectionStringTest.php
@@ -0,0 +1,101 @@
+assertSame('postgres', $connectionString->scheme);
+ $this->assertSame('db.example.com', $connectionString->host);
+ $this->assertSame(5433, $connectionString->port);
+ $this->assertSame('us:er', $connectionString->username);
+ $this->assertSame('p@ss', $connectionString->password);
+ $this->assertSame('/tempest', $connectionString->path);
+ $this->assertSame('tempest', $connectionString->database);
+ $this->assertSame(['sslmode' => 'require', 'connect_timeout' => '10'], $connectionString->parameters);
+ }
+
+ #[Test]
+ public function missing_components_are_null(): void
+ {
+ $connectionString = DatabaseConnectionString::from('MySQL://db.example.com');
+
+ $this->assertSame('mysql', $connectionString->scheme);
+ $this->assertSame('db.example.com', $connectionString->host);
+ $this->assertNull($connectionString->port);
+ $this->assertNull($connectionString->username);
+ $this->assertNull($connectionString->password);
+ $this->assertNull($connectionString->path);
+ $this->assertNull($connectionString->database);
+ $this->assertSame([], $connectionString->parameters);
+ }
+
+ #[Test]
+ public function unix_socket_host_is_decoded(): void
+ {
+ $connectionString = DatabaseConnectionString::from('postgresql://user@%2Fvar%2Frun%2Fpostgresql/tempest');
+
+ $this->assertSame('/var/run/postgresql', $connectionString->host);
+ $this->assertNull($connectionString->port);
+ $this->assertSame('tempest', $connectionString->database);
+ }
+
+ #[Test]
+ public function host_may_be_omitted(): void
+ {
+ $connectionString = DatabaseConnectionString::from('postgresql:///tempest?host=/var/run/postgresql');
+
+ $this->assertNull($connectionString->host);
+ $this->assertSame('tempest', $connectionString->database);
+ $this->assertSame(['host' => '/var/run/postgresql'], $connectionString->parameters);
+ }
+
+ #[Test]
+ public function ipv6_host_is_parsed(): void
+ {
+ $connectionString = DatabaseConnectionString::from('mysql://[::1]:3307/tempest');
+
+ $this->assertSame('[::1]', $connectionString->host);
+ $this->assertSame(3307, $connectionString->port);
+ }
+
+ #[Test]
+ public function connection_string_without_authority_is_parsed(): void
+ {
+ $connectionString = DatabaseConnectionString::from('sqlite::memory:');
+
+ $this->assertSame('sqlite', $connectionString->scheme);
+ $this->assertNull($connectionString->host);
+ $this->assertSame(':memory:', $connectionString->path);
+ }
+
+ #[Test]
+ public function connection_string_without_scheme_throws(): void
+ {
+ $this->expectException(DatabaseConnectionStringWasInvalid::class);
+
+ DatabaseConnectionString::from('db.example.com');
+ }
+
+ #[Test]
+ public function invalid_port_throws(): void
+ {
+ $this->expectException(DatabaseConnectionStringWasInvalid::class);
+
+ DatabaseConnectionString::from('mysql://db.example.com:port/tempest');
+ }
+}
diff --git a/packages/database/tests/QueryStatements/CreateTableStatementTest.php b/packages/database/tests/QueryStatements/CreateTableStatementTest.php
index b6322454a6..36b0d18de8 100644
--- a/packages/database/tests/QueryStatements/CreateTableStatementTest.php
+++ b/packages/database/tests/QueryStatements/CreateTableStatementTest.php
@@ -113,7 +113,7 @@ public static function provide_fk_create_table_database_drivers(): Generator
<<uuid()
+ ->belongsToUuid('books.author_id', 'authors.id', OnDelete::CASCADE)
+ ->varchar('name')
+ ->compile($dialect);
+
+ $this->assertSame($validSql, $statement);
+
+ $statement = new CreateTableStatement('books')
+ ->uuid()
+ ->foreignUuid('author_id', constrainedOn: 'authors', onDelete: OnDelete::CASCADE)
+ ->varchar('name')
+ ->compile($dialect);
+
+ $this->assertSame($validSql, $statement);
+
+ $statement = new CreateTableStatement('books')
+ ->uuid()
+ ->foreignUuid('books.author_id', constrainedOn: 'authors.id', onDelete: OnDelete::CASCADE)
+ ->varchar('name')
+ ->compile($dialect);
+
+ $this->assertSame($validSql, $statement);
+ }
+
+ public static function provide_uuid_fk_create_table_database_dialects(): Generator
+ {
+ yield 'mysql' => [
+ DatabaseDialect::MYSQL,
+ << [
+ DatabaseDialect::POSTGRESQL,
+ << [
+ DatabaseDialect::SQLITE,
+ <<primary(unsigned: true)
+ ->belongsTo('books.author_id', 'authors.id', OnDelete::CASCADE, unsigned: true)
+ ->varchar('name')
+ ->compile($dialect);
+
+ $this->assertSame($validSql, $statement);
+
+ $statement = new CreateTableStatement('books')
+ ->primary(unsigned: true)
+ ->foreignId('author_id', constrainedOn: 'authors', onDelete: OnDelete::CASCADE, unsigned: true)
+ ->varchar('name')
+ ->compile($dialect);
+
+ $this->assertSame($validSql, $statement);
+ }
+
+ public static function provide_unsigned_fk_create_table_database_drivers(): Generator
+ {
+ yield 'mysql' => [
+ DatabaseDialect::MYSQL,
+ << [
+ DatabaseDialect::POSTGRESQL,
+ << [
+ DatabaseDialect::SQLITE,
+ <<primary(unsigned: true)
+ ->text('name')
+ ->compile($dialect);
+
+ $this->assertSame($validSql, $statement);
+ }
+
+ public static function provide_unsigned_primary_database_dialects(): iterable
+ {
+ yield 'mysql' => [
+ DatabaseDialect::MYSQL,
+ << [
+ DatabaseDialect::POSTGRESQL,
+ << [
+ DatabaseDialect::SQLITE,
+ <<compile(DatabaseDialect::MYSQL);
+
+ $this->assertSame('`author_id` CHAR(36) NOT NULL', $compiled);
+ }
+
+ #[Test]
+ public function postgresql_compilation(): void
+ {
+ $statement = new UuidStatement('author_id');
+ $compiled = $statement->compile(DatabaseDialect::POSTGRESQL);
+
+ $this->assertSame('"author_id" UUID NOT NULL', $compiled);
+ }
+
+ #[Test]
+ public function sqlite_compilation(): void
+ {
+ $statement = new UuidStatement('author_id');
+ $compiled = $statement->compile(DatabaseDialect::SQLITE);
+
+ $this->assertSame('`author_id` TEXT NOT NULL', $compiled);
+ }
+
+ #[Test]
+ public function nullable_compilation(): void
+ {
+ $statement = new UuidStatement('author_id', nullable: true);
+
+ $this->assertSame('`author_id` CHAR(36)', $statement->compile(DatabaseDialect::MYSQL));
+ $this->assertSame('"author_id" UUID', $statement->compile(DatabaseDialect::POSTGRESQL));
+ $this->assertSame('`author_id` TEXT', $statement->compile(DatabaseDialect::SQLITE));
+ }
+}
diff --git a/packages/debug/src/Debug.php b/packages/debug/src/Debug.php
index cd17c4ef24..9fa7461ad9 100644
--- a/packages/debug/src/Debug.php
+++ b/packages/debug/src/Debug.php
@@ -23,6 +23,12 @@ private function __construct(
public static function resolve(): self
{
try {
+ $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, limit: 2);
+
+ if (str_ends_with($trace[1]['file'] ?? '', 'container/src/GenericContainer.php')) {
+ return new self();
+ }
+
return new self(
config: GenericContainer::instance()->get(DebugConfig::class),
eventBus: GenericContainer::instance()->get(EventBus::class),
diff --git a/packages/generation/composer.json b/packages/generation/composer.json
index c77fda6d55..783a7c4451 100644
--- a/packages/generation/composer.json
+++ b/packages/generation/composer.json
@@ -5,9 +5,7 @@
"minimum-stability": "dev",
"require": {
"php": "^8.5",
- "nette/php-generator": "4.2.1",
- "nette/schema": "^1.3.4",
- "nikic/php-parser": "^5.3",
+ "nikic/php-parser": "^5.9",
"tempest/support": "3.x-dev",
"psr/container": "^2.0"
},
diff --git a/packages/generation/src/Php/Attributes.php b/packages/generation/src/Php/Attributes.php
new file mode 100644
index 0000000000..cf7cd663cb
--- /dev/null
+++ b/packages/generation/src/Php/Attributes.php
@@ -0,0 +1,51 @@
+ $arguments
+ */
+ public static function add(Stmt\Class_|Stmt\ClassMethod|Param $node, string $name, array $arguments = []): void
+ {
+ $node->attrGroups[] = new AttributeGroup([
+ new Attribute(new Name\FullyQualified(ltrim($name, '\\')), ValueExporter::exportArguments($arguments)),
+ ]);
+ }
+
+ public static function remove(Stmt\Class_|Stmt\ClassMethod|Param $node, string $name): void
+ {
+ $groups = [];
+
+ foreach ($node->attrGroups as $group) {
+ $attributes = array_values(array_filter(
+ $group->attrs,
+ static fn (Attribute $attribute) => ! Names::refersTo($attribute->name, $name),
+ ));
+
+ if ($attributes === []) {
+ continue;
+ }
+
+ if (count($attributes) !== count($group->attrs)) {
+ $group->attrs = $attributes;
+ }
+
+ $groups[] = $group;
+ }
+
+ $node->attrGroups = $groups;
+ }
+}
diff --git a/packages/generation/src/Php/Changes.php b/packages/generation/src/Php/Changes.php
new file mode 100644
index 0000000000..1eaa735ead
--- /dev/null
+++ b/packages/generation/src/Php/Changes.php
@@ -0,0 +1,86 @@
+ true,
+ Stmt\Use_::class => true,
+ Stmt\GroupUse::class => true,
+ Stmt\Class_::class => true,
+ Stmt\ClassMethod::class => true,
+ Stmt\Declare_::class => true,
+ Node\DeclareItem::class => true,
+ Node\AttributeGroup::class => true,
+ Node\Param::class => true,
+ Node\UseItem::class => true,
+ ];
+
+ /**
+ * Marks a node as changed, returning whether it wasn't marked yet.
+ */
+ public static function mark(Node $node): bool
+ {
+ if ($node->getAttribute(self::CHANGED_ATTRIBUTE) === true) {
+ return false;
+ }
+
+ $node->setAttribute(self::CHANGED_ATTRIBUTE, true);
+
+ return true;
+ }
+
+ public static function isUnchanged(Node $node): bool
+ {
+ $original = $node->getAttribute('origNode');
+
+ if (! $original instanceof Node || $node->getAttribute(self::CHANGED_ATTRIBUTE) === true) {
+ return false;
+ }
+
+ if (! isset(self::MUTABLE_NODES[$node::class])) {
+ return true;
+ }
+
+ if ($node->getComments() !== $original->getComments()) {
+ return false;
+ }
+
+ return array_all($node->getSubNodeNames(), static fn (string $name) => self::isSame($node->{$name}, $original->{$name}));
+ }
+
+ private static function isSame(mixed $value, mixed $original): bool
+ {
+ if ($value instanceof Node) {
+ return $value->getAttribute('origNode') === $original && self::isUnchanged($value);
+ }
+
+ if (! is_array($value)) {
+ return $value === $original;
+ }
+
+ if (! is_array($original) || count($value) !== count($original)) {
+ return false;
+ }
+
+ return array_all($value, static fn (mixed $item, int|string $key) => array_key_exists($key, $original) && self::isSame($item, $original[$key]));
+ }
+}
diff --git a/packages/generation/src/Php/ClassGenerator.php b/packages/generation/src/Php/ClassGenerator.php
index 848341ab05..c18e8d9ea7 100644
--- a/packages/generation/src/Php/ClassGenerator.php
+++ b/packages/generation/src/Php/ClassGenerator.php
@@ -4,17 +4,13 @@
namespace Tempest\Generation\Php;
-use Nette\PhpGenerator\ClassType;
-use Nette\PhpGenerator\PhpFile;
-
final class ClassGenerator
{
use ManipulatesPhpClasses;
public function __construct(string $name, string $namespace)
{
- $this->file = new PhpFile();
- $this->classType = new ClassType($name);
- $this->namespace = $namespace;
+ $this->file = PhpFile::create($namespace);
+ $this->class = $this->file->addClass($name);
}
}
diff --git a/packages/generation/src/Php/ClassManipulator.php b/packages/generation/src/Php/ClassManipulator.php
index b227a794f5..d2c0f72614 100644
--- a/packages/generation/src/Php/ClassManipulator.php
+++ b/packages/generation/src/Php/ClassManipulator.php
@@ -4,8 +4,6 @@
namespace Tempest\Generation\Php;
-use Nette\PhpGenerator\ClassType;
-use Nette\PhpGenerator\PhpFile;
use ReflectionClass;
use Tempest\Generation\Php\Exceptions\FileGenerationFailedException;
use Tempest\Support\Filesystem;
@@ -15,18 +13,29 @@ final class ClassManipulator
{
use ManipulatesPhpClasses;
- public function __construct(string|ReflectionClass $source)
+ /**
+ * @param string|class-string|ReflectionClass|PhpFile $source a file path, a class name, or the loaded file
+ */
+ public function __construct(string|ReflectionClass|PhpFile $source)
{
- if (is_file($source)) {
- $this->classType = ClassType::fromCode(Filesystem\read_file($source));
- } elseif (is_string($source)) {
- $this->classType = ClassType::from($source, withBodies: true);
- } else {
- $this->classType = ClassType::from($source->getName(), withBodies: true);
+ if ($source instanceof PhpFile) {
+ $this->file = $source;
+ $this->class = $source->getClass();
+
+ return;
+ }
+
+ if (is_string($source) && is_file($source)) {
+ $this->file = PhpFile::fromPath($source);
+ $this->class = $this->file->getClass();
+
+ return;
}
- $this->file = new PhpFile();
- $this->namespace = $this->classType->getNamespace()->getName();
+ $reflection = is_string($source) ? new ReflectionClass($source) : $source;
+
+ $this->file = PhpFile::fromPath($reflection->getFileName());
+ $this->class = $this->file->getClass($reflection->getShortName());
}
/**
@@ -38,13 +47,8 @@ public function __construct(string|ReflectionClass $source)
*/
public function save(string $path): self
{
- $dir = pathinfo($path, PATHINFO_DIRNAME);
-
- if (! is_dir($dir)) {
- mkdir($dir, recursive: true);
- }
-
try {
+ Filesystem\create_directory_for_file($path);
Filesystem\write_file($path, $this->print());
} catch (FilesystemException) {
throw new FileGenerationFailedException(sprintf('The file "%s" could not be written.', $path));
diff --git a/packages/generation/src/Php/Cloner.php b/packages/generation/src/Php/Cloner.php
new file mode 100644
index 0000000000..a61f8e8c39
--- /dev/null
+++ b/packages/generation/src/Php/Cloner.php
@@ -0,0 +1,167 @@
+ keys of the imports that names rely on */
+ public array $importKeys = [];
+
+ private ?string $namespace = null;
+
+ /** @var array> imported names by type and alias */
+ private array $imports = [];
+
+ /**
+ * @param Stmt[] $stmts
+ * @return Stmt[]
+ */
+ public function cloneStatements(array $stmts): array
+ {
+ /** @var Stmt[] */
+ return $this->cloneValue($stmts, Stmt\Use_::TYPE_NORMAL);
+ }
+
+ private function cloneValue(mixed $value, int $nameType): mixed
+ {
+ if ($value instanceof Node) {
+ return $this->cloneNode($value, $nameType);
+ }
+
+ if (! is_array($value)) {
+ return $value;
+ }
+
+ foreach ($value as $key => $item) {
+ $value[$key] = $this->cloneValue($item, $nameType);
+ }
+
+ return $value;
+ }
+
+ private function cloneNode(Node $node, int $nameType): Node
+ {
+ $clone = clone $node;
+ $clone->setAttribute('origNode', $node);
+
+ if ($clone instanceof Name) {
+ $this->resolve($clone, $nameType);
+
+ return $clone;
+ }
+
+ if ($node instanceof Stmt\Namespace_) {
+ $this->namespace = $node->name?->toString();
+ $this->imports = [];
+ } elseif ($node instanceof Stmt\Use_ || $node instanceof Stmt\GroupUse) {
+ $this->import($node);
+ }
+
+ $nameType = $this->nameTypeOf($node);
+
+ foreach ($node->getSubNodeNames() as $subNodeName) {
+ $clone->{$subNodeName} = $this->cloneValue($node->{$subNodeName}, $nameType);
+ }
+
+ return $clone;
+ }
+
+ /**
+ * Returns the type of the names that are direct children of a node.
+ */
+ private function nameTypeOf(Node $node): int
+ {
+ return match (true) {
+ $node instanceof Stmt\Namespace_, $node instanceof Stmt\Use_, $node instanceof Stmt\GroupUse, $node instanceof Node\UseItem => self::UNRESOLVED,
+ $node instanceof Expr\FuncCall => Stmt\Use_::TYPE_FUNCTION,
+ $node instanceof Expr\ConstFetch => Stmt\Use_::TYPE_CONSTANT,
+ default => Stmt\Use_::TYPE_NORMAL,
+ };
+ }
+
+ private function import(Stmt\Use_|Stmt\GroupUse $node): void
+ {
+ foreach ($node->uses as $item) {
+ $type = $node->type !== Stmt\Use_::TYPE_UNKNOWN ? $node->type : $item->type;
+ $name = $node instanceof Stmt\GroupUse ? Name::concat($node->prefix, $item->name) : $item->name;
+
+ $this->imports[$type][self::aliasKey($type, $item->getAlias()->toString())] = $name->toString();
+ }
+ }
+
+ private function resolve(Name $name, int $type): void
+ {
+ if ($type === self::UNRESOLVED) {
+ return;
+ }
+
+ $importKey = Imports::keyOf($name, $type);
+
+ if ($importKey !== null) {
+ $this->importKeys[$importKey] = true;
+ }
+
+ if ($name->isFullyQualified() || $name->isSpecialClassName()) {
+ return;
+ }
+
+ if ($name->isRelative()) {
+ $resolved = $this->prefixNamespace($name->toString());
+ } elseif ($type !== Stmt\Use_::TYPE_NORMAL && $name->isUnqualified()) {
+ $resolved = $this->imports[$type][self::aliasKey($type, $name->toString())] ?? $this->resolveGlobally($name, $type);
+ } else {
+ $import = $this->imports[Stmt\Use_::TYPE_NORMAL][strtolower($name->getFirst())] ?? null;
+ $resolved = match (true) {
+ $import === null => $this->prefixNamespace($name->toString()),
+ $name->isQualified() => $import . '\\' . $name->slice(1)?->toString(),
+ default => $import,
+ };
+ }
+
+ if ($resolved !== null) {
+ $name->setAttribute(Names::RESOLVED_NAME_ATTRIBUTE, new Name\FullyQualified($resolved));
+ }
+ }
+
+ /**
+ * Unqualified functions and constants only refer to a known definition outside of a namespace,
+ * since PHP falls back to the global one inside a namespace.
+ */
+ private function resolveGlobally(Name $name, int $type): ?string
+ {
+ if ($this->namespace !== null) {
+ return null;
+ }
+
+ if ($type === Stmt\Use_::TYPE_CONSTANT && in_array($name->toLowerString(), ['true', 'false', 'null'], strict: true)) {
+ return null;
+ }
+
+ return $name->toString();
+ }
+
+ private function prefixNamespace(string $name): string
+ {
+ return $this->namespace === null ? $name : $this->namespace . '\\' . $name;
+ }
+
+ private static function aliasKey(int $type, string $alias): string
+ {
+ return $type === Stmt\Use_::TYPE_CONSTANT ? $alias : strtolower($alias);
+ }
+}
diff --git a/packages/generation/src/Php/DataObjects/StubFile.php b/packages/generation/src/Php/DataObjects/StubFile.php
index f0b57fe640..c391aa4406 100644
--- a/packages/generation/src/Php/DataObjects/StubFile.php
+++ b/packages/generation/src/Php/DataObjects/StubFile.php
@@ -5,10 +5,12 @@
namespace Tempest\Generation\Php\DataObjects;
use InvalidArgumentException;
-use Nette\InvalidStateException;
-use ReflectionException;
-use Tempest\Generation\Php\ClassManipulator;
+use PhpParser\Node\Stmt;
+use ReflectionClass;
+use Tempest\Generation\Php\Exceptions\PhpCodeWasInvalid;
+use Tempest\Generation\Php\SourceParser;
use Tempest\Generation\Php\StubFileType;
+use Tempest\Support\Filesystem;
/**
* Represents a file that is to be generated.
@@ -25,22 +27,45 @@ public function __construct(
*/
public static function from(string $pathOrClass): self
{
- try {
- new ClassManipulator($pathOrClass);
-
+ if (class_exists($pathOrClass)) {
return new self(
- filePath: $pathOrClass,
+ filePath: new ReflectionClass($pathOrClass)->getFileName(),
type: StubFileType::CLASS_FILE,
);
- } catch (InvalidStateException|ReflectionException) {
- if (! file_exists($pathOrClass)) {
- throw new InvalidArgumentException(sprintf('The file "%s" does not exist.', $pathOrClass));
+ }
+
+ if (! is_file($pathOrClass)) {
+ throw new InvalidArgumentException(sprintf('The file "%s" does not exist.', $pathOrClass));
+ }
+
+ return new self(
+ filePath: $pathOrClass,
+ type: self::declaresClass($pathOrClass) ? StubFileType::CLASS_FILE : StubFileType::RAW_FILE,
+ );
+ }
+
+ /**
+ * Whether the file is valid PHP that declares a class where `PhpFile::getClass()` finds it:
+ * at the top level, or in its first namespace.
+ */
+ private static function declaresClass(string $path): bool
+ {
+ try {
+ [$stmts] = SourceParser::parse(Filesystem\read_file($path));
+ } catch (PhpCodeWasInvalid) {
+ return false;
+ }
+
+ foreach ($stmts as $stmt) {
+ if (! $stmt instanceof Stmt\Namespace_) {
+ continue;
}
- return new self(
- filePath: $pathOrClass,
- type: StubFileType::RAW_FILE,
- );
+ $stmts = $stmt->stmts;
+
+ break;
}
+
+ return array_any($stmts, static fn (Stmt $stmt) => $stmt instanceof Stmt\Class_);
}
}
diff --git a/packages/generation/src/Php/DocComment.php b/packages/generation/src/Php/DocComment.php
new file mode 100644
index 0000000000..7aac8afaf1
--- /dev/null
+++ b/packages/generation/src/Php/DocComment.php
@@ -0,0 +1,23 @@
+ rtrim(' * ' . $line),
+ explode("\n", trim($text)),
+ );
+
+ return new Doc("/**\n" . implode("\n", $lines) . "\n */");
+ }
+}
diff --git a/packages/generation/src/Php/Exceptions/ClassWasMissing.php b/packages/generation/src/Php/Exceptions/ClassWasMissing.php
new file mode 100644
index 0000000000..c9a6c66c32
--- /dev/null
+++ b/packages/generation/src/Php/Exceptions/ClassWasMissing.php
@@ -0,0 +1,18 @@
+ 'The PHP code does not declare a class.',
+ default => sprintf('The PHP code does not declare a class named "%s".', $name),
+ });
+ }
+}
diff --git a/packages/generation/src/Php/Exceptions/MethodWasMissing.php b/packages/generation/src/Php/Exceptions/MethodWasMissing.php
new file mode 100644
index 0000000000..cc59ccfd2e
--- /dev/null
+++ b/packages/generation/src/Php/Exceptions/MethodWasMissing.php
@@ -0,0 +1,15 @@
+getMessage()), previous: $previous);
+ }
+}
diff --git a/packages/generation/src/Php/Exceptions/ValueWasNotExportable.php b/packages/generation/src/Php/Exceptions/ValueWasNotExportable.php
new file mode 100644
index 0000000000..b5ddb86ed0
--- /dev/null
+++ b/packages/generation/src/Php/Exceptions/ValueWasNotExportable.php
@@ -0,0 +1,15 @@
+ import key to fully qualified name */
+ private array $names = [];
+
+ /** @var array lowercased type and fully qualified name to alias */
+ private array $aliases = [];
+
+ /** @var array */
+ private array $pending = [];
+
+ /**
+ * @param Stmt[] $stmts
+ */
+ public static function from(array $stmts): self
+ {
+ $imports = new self();
+
+ foreach ($stmts as $stmt) {
+ if ($stmt instanceof Stmt\Use_) {
+ foreach ($stmt->uses as $item) {
+ $imports->register($stmt->type, $item->name->toString(), $item->getAlias()->toString());
+ }
+ } elseif ($stmt instanceof Stmt\GroupUse) {
+ foreach ($stmt->uses as $item) {
+ $type = $stmt->type !== Stmt\Use_::TYPE_UNKNOWN ? $stmt->type : $item->type;
+ $imports->register($type, Name::concat($stmt->prefix, $item->name)->toString(), $item->getAlias()->toString());
+ }
+ }
+ }
+
+ return $imports;
+ }
+
+ public static function key(int $type, string $alias): string
+ {
+ return $type . ':' . strtolower($alias);
+ }
+
+ /**
+ * Returns the key of the import a name relies on, if any.
+ */
+ public static function keyOf(Name $name, int $type): ?string
+ {
+ if ($name->isFullyQualified() || $name->isRelative() || $name->isSpecialClassName()) {
+ return null;
+ }
+
+ if ($name->isQualified()) {
+ return self::key(Stmt\Use_::TYPE_NORMAL, $name->getFirst());
+ }
+
+ return self::key($type, $name->getFirst());
+ }
+
+ public function resolve(int $type, string $alias): ?string
+ {
+ return $this->names[self::key($type, $alias)] ?? null;
+ }
+
+ public function aliasOf(int $type, string $name): ?string
+ {
+ return $this->aliases[self::key($type, $name)] ?? null;
+ }
+
+ /**
+ * Resolves a class name, or a qualified function or constant name, as it would be in the given namespace.
+ */
+ public function resolveClassName(Name $name, ?string $namespace): string
+ {
+ if ($name->isFullyQualified()) {
+ return $name->toString();
+ }
+
+ if (! $name->isRelative()) {
+ $import = $this->resolve(Stmt\Use_::TYPE_NORMAL, $name->getFirst());
+
+ if ($import !== null) {
+ return $name->isQualified() ? $import . '\\' . $name->slice(1)?->toString() : $import;
+ }
+ }
+
+ return ltrim($namespace . '\\' . $name->toString(), '\\');
+ }
+
+ /**
+ * @param Stmt\Use_::TYPE_* $type
+ */
+ public function add(int $type, string $name, ?string $alias = null): void
+ {
+ $alias ??= Names::lastSegment($name);
+
+ $this->register($type, $name, $alias);
+ $this->pending[self::key($type, $name)] = [$type, $name, $alias];
+ }
+
+ /**
+ * Inserts the imports that were added into the given statements.
+ *
+ * @param Stmt[] $stmts
+ */
+ public function insertInto(array &$stmts): void
+ {
+ $pending = $this->pending;
+ $this->pending = [];
+
+ uasort($pending, static fn (array $a, array $b) => strcasecmp($a[1], $b[1]));
+
+ foreach ($pending as [$type, $name, $alias]) {
+ $item = new UseItem(new Name($name), $alias !== Names::lastSegment($name) ? new Identifier($alias) : null);
+
+ if ($this->updateExistingImport($stmts, $type, $name, $item)) {
+ continue;
+ }
+
+ self::insertImport($stmts, $type, new Stmt\Use_([$item], $type));
+ }
+ }
+
+ /**
+ * @param Stmt[] $stmts
+ * @param array $used
+ */
+ public static function hasUnused(array $stmts, array $used): bool
+ {
+ foreach ($stmts as $stmt) {
+ if (! $stmt instanceof Stmt\Use_) {
+ continue;
+ }
+
+ if (array_any($stmt->uses, static fn (UseItem $item) => ! isset($used[self::key($stmt->type, $item->getAlias()->toString())]))) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Removes the imports that were relied on before, but no longer are.
+ * Imports that are mentioned in comments are kept, since doc comments may refer to them.
+ *
+ * @param Stmt[] $stmts
+ * @param array $previouslyUsed
+ * @param array $used
+ */
+ public static function removeUnused(array &$stmts, array $previouslyUsed, array $used, string $comments): void
+ {
+ foreach ($stmts as $index => $stmt) {
+ if (! $stmt instanceof Stmt\Use_) {
+ continue;
+ }
+
+ $items = array_values(array_filter(
+ $stmt->uses,
+ static function (UseItem $item) use ($stmt, $previouslyUsed, $used, $comments): bool {
+ $alias = $item->getAlias()->toString();
+ $key = self::key($stmt->type, $alias);
+
+ if (! isset($previouslyUsed[$key]) || isset($used[$key])) {
+ return true;
+ }
+
+ return preg_match('/(?uses)) {
+ $stmt->uses = $items;
+ }
+ }
+
+ $stmts = array_values($stmts);
+ }
+
+ private function register(int $type, string $name, string $alias): void
+ {
+ $this->names[self::key($type, $alias)] = $name;
+ $this->aliases[self::key($type, $name)] = $alias;
+ }
+
+ /**
+ * @param Stmt[] $stmts
+ */
+ private function updateExistingImport(array $stmts, int $type, string $name, UseItem $replacement): bool
+ {
+ foreach ($stmts as $stmt) {
+ $items = match (true) {
+ $stmt instanceof Stmt\Use_ && $stmt->type === $type => $stmt->uses,
+ $stmt instanceof Stmt\GroupUse => array_filter(
+ $stmt->uses,
+ static fn (UseItem $item) => ($stmt->type !== Stmt\Use_::TYPE_UNKNOWN ? $stmt->type : $item->type) === $type,
+ ),
+ default => [],
+ };
+
+ foreach ($items as $item) {
+ $itemName = $stmt instanceof Stmt\GroupUse ? Name::concat($stmt->prefix, $item->name) : $item->name;
+
+ if (strcasecmp($itemName->toString(), $name) !== 0) {
+ continue;
+ }
+
+ if ($item->getAlias()->toString() !== $replacement->getAlias()->toString()) {
+ $item->alias = $replacement->alias;
+ }
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param Stmt[] $stmts
+ */
+ private static function insertImport(array &$stmts, int $type, Stmt\Use_ $import): void
+ {
+ $name = $import->uses[0]->name->toString();
+ $lastImportBefore = null;
+ $lastImportType = null;
+
+ foreach ($stmts as $index => $stmt) {
+ if (! $stmt instanceof Stmt\Use_ && ! $stmt instanceof Stmt\GroupUse && ! $stmt instanceof Stmt\Declare_ && ! Printer::isBlankLine($stmt)) {
+ break;
+ }
+
+ if (! $stmt instanceof Stmt\Use_) {
+ continue;
+ }
+
+ if ($stmt->type === $type) {
+ if (strcasecmp($stmt->uses[0]->name->toString(), $name) > 0) {
+ array_splice($stmts, $index, 0, [$import]);
+
+ return;
+ }
+
+ $lastImportBefore = $index;
+ $lastImportType = $stmt->type;
+ } elseif ($stmt->type < $type) {
+ $lastImportBefore = $index;
+ $lastImportType = $stmt->type;
+ } elseif ($lastImportBefore === null || $lastImportType !== $type) {
+ $position = $lastImportBefore !== null ? $lastImportBefore + 1 : $index;
+ $replacement = $lastImportBefore !== null ? [Printer::blankLine(), $import] : [$import, Printer::blankLine()];
+
+ array_splice($stmts, $position, 0, $replacement);
+
+ return;
+ } else {
+ array_splice($stmts, $lastImportBefore + 1, 0, [$import]);
+
+ return;
+ }
+ }
+
+ if ($lastImportBefore !== null) {
+ array_splice($stmts, $lastImportBefore + 1, 0, $lastImportType === $type ? [$import] : [Printer::blankLine(), $import]);
+
+ return;
+ }
+
+ $position = 0;
+
+ while (isset($stmts[$position]) && $stmts[$position] instanceof Stmt\Declare_) {
+ $position++;
+ }
+
+ array_splice($stmts, $position, 0, $stmts === [] ? [$import] : [$import, Printer::blankLine()]);
+ }
+}
diff --git a/packages/generation/src/Php/Literal.php b/packages/generation/src/Php/Literal.php
new file mode 100644
index 0000000000..438c6e7589
--- /dev/null
+++ b/packages/generation/src/Php/Literal.php
@@ -0,0 +1,15 @@
+ */
- protected array $manipulations = [];
+ protected PhpClass $class;
+ /**
+ * @param array $parameters parameter names mapped to their types
+ */
public function addMethod(
string $name,
string $body = '',
array $parameters = [],
string $returnType = 'void',
): self {
- $method = $this->classType
- ->addMethod($name, overwrite: true)
+ $method = $this->class
+ ->addMethod($name)
->setReturnType($returnType)
->setBody($body);
foreach ($parameters as $parameter => $type) {
- $method
- ->addParameter($parameter)
- ->setType($type);
+ $method->addParameter($parameter, $type);
}
return $this;
}
- public function manipulate(Closure $callback): self
- {
- $this->manipulations[] = $callback;
-
- return $this;
- }
-
public function setClassName(string $name): self
{
- $this->classType->setName($name);
+ $this->class->setName($name);
return $this;
}
public function removeMethod(string $name): self
{
- $this->classType->removeMethod($name);
+ $this->class->removeMethod($name);
return $this;
}
+ /**
+ * @param array $arguments
+ */
public function addMethodAttribute(string $method, string $attribute, array $arguments = []): self
{
- $method = $this->classType->getMethod($method);
- $method->addAttribute($attribute, $arguments);
+ $this->class->getMethod($method)->addAttribute($attribute, $arguments);
return $this;
}
+ /**
+ * @param array $arguments
+ */
public function addClassAttribute(string $attributeName, array $arguments = []): self
{
- $this->classType->addAttribute($attributeName, $arguments);
+ $this->class->addAttribute($attributeName, $arguments);
return $this;
}
public function removeClassAttribute(string $attributeName): self
{
- $attributes = $this->classType->getAttributes();
-
- foreach ($attributes as $key => $attribute) {
- if ($attribute->getName() !== $attributeName) {
- continue;
- }
-
- unset($attributes[$key]);
- }
-
- $this->classType->setAttributes($attributes);
+ $this->class->removeAttribute($attributeName);
return $this;
}
@@ -110,21 +86,21 @@ public function setStrictTypes(bool $strictTypes = true): self
public function setFinal(bool $final = true): self
{
- $this->classType->setFinal($final);
+ $this->class->setFinal($final);
return $this;
}
public function setReadOnly(bool $readonly = true): self
{
- $this->classType->setReadOnly($readonly);
+ $this->class->setReadOnly($readonly);
return $this;
}
public function setAbstract(bool $abstract = true): self
{
- $this->classType->setAbstract($abstract);
+ $this->class->setAbstract($abstract);
return $this;
}
@@ -138,66 +114,78 @@ public function setFileComment(?string $comment): self
public function setClassComment(?string $comment): self
{
- $this->classType->setComment($comment);
+ $this->class->setComment($comment);
return $this;
}
public function addImplement(string $interface): self
{
- $this->classType->addImplement($interface);
+ $this->class->addImplement($interface);
return $this;
}
public function addExtend(string $class): self
{
- $this->classType->setExtends($class);
+ $this->class->setExtends($class);
return $this;
}
public function addTrait(string $trait): self
{
- $this->classType->addTrait($trait);
+ $this->class->addTrait($trait);
return $this;
}
public function setNamespace(string $namespace): self
{
- $this->namespace = $namespace;
+ $this->file->setNamespace($namespace);
return $this;
}
- public function getClassName(): string
+ public function simplifyClassNamesInMethodBodies(bool $simplify = true): self
{
- return $this->namespace . '\\' . $this->classType->getName();
+ $this->file->simplifyNamesInBodies($simplify);
+
+ return $this;
}
- public function print(): string
+ public function simplifyImplements(bool $simplify = true): self
{
- if (! $this->namespace) {
- throw new GeneratedClassWasMissingANamespace();
- }
+ $this->file->simplifyImplements($simplify);
- $printer = new PsrPrinter();
+ return $this;
+ }
- $file = clone $this->file;
- $namespace = $file->addNamespace($this->namespace);
- $namespace->add($this->classType);
+ public function addUse(string $use): self
+ {
+ $this->file->addUse($use);
- $file = $this->simplifyClassNames($file);
- $code = $printer->printFile($file);
+ return $this;
+ }
- foreach ($this->manipulations as $manipulation) {
- $code = (string) $manipulation(str($code));
- }
+ public function setAlias(string $class, string $alias): self
+ {
+ $this->file->addUse($class, $alias);
+
+ return $this;
+ }
+
+ public function getClassName(): string
+ {
+ return ltrim($this->file->getNamespace() . '\\' . $this->class->getName(), '\\');
+ }
- $updatedFile = $file->fromCode($code);
- $updatedFile = $this->simplifyClassNames($updatedFile);
+ public function print(): string
+ {
+ if (! $this->file->getNamespace()) {
+ throw new GeneratedClassWasMissingANamespace();
+ }
- return $printer->printFile($updatedFile);
+ return $this->file->print();
}
}
diff --git a/packages/generation/src/Php/MethodBody.php b/packages/generation/src/Php/MethodBody.php
new file mode 100644
index 0000000000..a3dfb3fb16
--- /dev/null
+++ b/packages/generation/src/Php/MethodBody.php
@@ -0,0 +1,170 @@
+ replacements keyed by their offset, with the replaced length and code */
+ private array $replacements = [];
+
+ /**
+ * @param array $verbatimLines lines whose indentation is part of a string's value
+ */
+ private function __construct(
+ private readonly string $code,
+ private readonly int $indentation,
+ private readonly array $verbatimLines,
+ private readonly string $comments,
+ ) {}
+
+ /**
+ * @return array{self, Stmt[]}
+ */
+ public static function parse(string $code): array
+ {
+ $code = trim($code, "\n");
+
+ [$stmts, $tokens] = SourceParser::parse(self::OPEN_TAG . $code);
+
+ $lines = explode("\n", $code);
+ $verbatimLines = self::verbatimLines($stmts, $lines);
+ $comments = '';
+
+ foreach ($tokens as $token) {
+ if (! $token->is([T_COMMENT, T_DOC_COMMENT])) {
+ continue;
+ }
+
+ $comments .= $token->text . "\n";
+ }
+
+ return [new self($code, self::indentationOf($lines, $verbatimLines), $verbatimLines, $comments), $stmts];
+ }
+
+ /**
+ * Replaces a name that was parsed from this body.
+ */
+ public function replaceName(Name $name, Name $replacement): void
+ {
+ $start = $name->getStartFilePos();
+
+ if ($start < 0) {
+ return;
+ }
+
+ $this->replacements[$start - strlen(self::OPEN_TAG)] = [$name->getEndFilePos() - $start + 1, $replacement->toCodeString()];
+ }
+
+ public function comments(): string
+ {
+ return $this->comments;
+ }
+
+ /**
+ * Prints the body with its own indentation replaced by the given one.
+ */
+ public function print(string $indentation): string
+ {
+ $code = $this->code;
+ $replacements = $this->replacements;
+
+ krsort($replacements);
+
+ foreach ($replacements as $offset => [$length, $replacement]) {
+ $code = substr_replace($code, $replacement, $offset, $length);
+ }
+
+ $lines = explode("\n", $code);
+
+ foreach ($lines as $index => $line) {
+ if ($line === '' || isset($this->verbatimLines[$index])) {
+ continue;
+ }
+
+ $lines[$index] = $indentation . substr($line, min($this->indentation, strspn($line, " \t")));
+ }
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * Finds the lines whose indentation is part of a string's value. Heredocs may be reindented as a whole,
+ * as long as they are indented with spaces only, since PHP strips the indentation of their closing marker.
+ *
+ * @param Stmt[] $stmts
+ * @param string[] $lines
+ * @return array
+ */
+ private static function verbatimLines(array $stmts, array $lines): array
+ {
+ $strings = new NodeFinder()->find(
+ $stmts,
+ static fn (Node $node) => (
+ ($node instanceof Scalar\String_ || $node instanceof Scalar\InterpolatedString || $node instanceof Expr\ShellExec)
+ && $node->getStartLine() !== $node->getEndLine()
+ ),
+ );
+
+ $verbatimLines = [];
+
+ foreach ($strings as $string) {
+ // The parser counts the opening tag as the first line, and a string's first line keeps its indentation.
+ $stringLines = range($string->getStartLine() - 1, $string->getEndLine() - 2);
+ $isHeredoc = in_array($string->getAttribute('kind'), [Scalar\String_::KIND_HEREDOC, Scalar\String_::KIND_NOWDOC], strict: true);
+
+ if ($isHeredoc && ! self::hasTabIndentation(array_intersect_key($lines, array_flip($stringLines)))) {
+ continue;
+ }
+
+ $verbatimLines += array_fill_keys($stringLines, true);
+ }
+
+ return $verbatimLines;
+ }
+
+ /**
+ * @param string[] $lines
+ * @param array $verbatimLines
+ */
+ private static function indentationOf(array $lines, array $verbatimLines): int
+ {
+ $indentation = PHP_INT_MAX;
+
+ foreach ($lines as $index => $line) {
+ if (isset($verbatimLines[$index]) || trim($line) === '') {
+ continue;
+ }
+
+ $indentation = min($indentation, strspn($line, " \t"));
+ }
+
+ return $indentation === PHP_INT_MAX ? 0 : $indentation;
+ }
+
+ /**
+ * @param string[] $lines
+ */
+ private static function hasTabIndentation(array $lines): bool
+ {
+ return array_any($lines, static fn (string $line) => str_contains(substr($line, 0, strspn($line, " \t")), "\t"));
+ }
+}
diff --git a/packages/generation/src/Php/NameSimplifier.php b/packages/generation/src/Php/NameSimplifier.php
new file mode 100644
index 0000000000..78fdb445e1
--- /dev/null
+++ b/packages/generation/src/Php/NameSimplifier.php
@@ -0,0 +1,321 @@
+ */
+ public array $usedImports = [];
+
+ /** @var array short names that refer to a class without an import, mapped to that class */
+ private array $reservedClasses = [];
+
+ /** @var array short names that refer to a function without an import */
+ private array $reservedFunctions = [];
+
+ /** @var list */
+ private array $names = [];
+
+ /** @var array */
+ private array $parents = [];
+
+ /**
+ * @param array $declaredClasses lowercased names of the classes declared in the namespace
+ */
+ public function __construct(
+ private readonly ?string $namespace,
+ private readonly Imports $imports,
+ private readonly array $declaredClasses,
+ private readonly bool $simplifyImplements,
+ private readonly bool $simplifyBodies,
+ ) {}
+
+ /**
+ * @param Stmt[] $stmts
+ */
+ public function simplify(array $stmts): void
+ {
+ foreach (array_keys($this->declaredClasses) as $class) {
+ $this->reservedClasses[$class] = ltrim($this->namespace . '\\' . $class, '\\');
+ }
+
+ foreach ($stmts as $stmt) {
+ $this->visit($stmt, null, Stmt\Use_::TYPE_NORMAL, inBody: false, inImplements: false, methodBody: null);
+ }
+
+ foreach ($this->names as [$name, $type, $inBody, $inImplements, $methodBody]) {
+ $simplified = match ($type) {
+ Stmt\Use_::TYPE_NORMAL => $this->simplifyClassName($name, $inBody, $inImplements),
+ Stmt\Use_::TYPE_FUNCTION => $this->simplifyFunctionName($name, $inBody),
+ default => $this->qualifyBrokenName($name, $type),
+ };
+
+ if ($simplified !== null) {
+ $this->replace($name, $simplified);
+ $methodBody?->replaceName($name, $simplified);
+ }
+
+ $key = Imports::keyOf($simplified ?? $name, $type);
+
+ if ($key !== null) {
+ $this->usedImports[$key] = true;
+ }
+ }
+ }
+
+ /**
+ * Collects every name along with the context it is used in. Import statements are skipped.
+ *
+ * @param int $nameType The `Stmt\Use_::TYPE_*` of a name visited directly, or `IGNORED`.
+ */
+ private function visit(Node $node, ?Node $parent, int $nameType, bool $inBody, bool $inImplements, ?MethodBody $methodBody): void
+ {
+ if ($parent !== null) {
+ $this->parents[spl_object_id($node)] = $parent;
+ }
+
+ if ($node instanceof Name) {
+ if ($nameType !== self::IGNORED) {
+ $this->reserve($node, $nameType);
+ $this->names[] = [$node, $nameType, $inBody, $inImplements, $methodBody];
+ }
+
+ return;
+ }
+
+ if ($node instanceof Stmt\Use_ || $node instanceof Stmt\GroupUse) {
+ return;
+ }
+
+ if ($node instanceof Stmt\Function_) {
+ $this->reservedFunctions[$node->name->toLowerString()] = true;
+ }
+
+ $childNameType = match (true) {
+ $node instanceof Stmt\Namespace_ => self::IGNORED,
+ $node instanceof Expr\FuncCall => Stmt\Use_::TYPE_FUNCTION,
+ $node instanceof Expr\ConstFetch => Stmt\Use_::TYPE_CONSTANT,
+ default => Stmt\Use_::TYPE_NORMAL,
+ };
+ $hasBody = $node instanceof Stmt\ClassMethod || $node instanceof Stmt\Function_;
+ $bodyMethodBody = $node instanceof Stmt\ClassMethod ? $node->getAttribute(MethodBody::ATTRIBUTE) ?? $methodBody : $methodBody;
+
+ foreach ($node->getSubNodeNames() as $subNodeName) {
+ $value = $node->{$subNodeName};
+ $isBody = $hasBody && $subNodeName === 'stmts';
+ $childInBody = $inBody || $isBody;
+ $childMethodBody = $isBody ? $bodyMethodBody : $methodBody;
+ $childInImplements = $node instanceof Stmt\Class_ && $subNodeName === 'implements';
+
+ foreach (is_array($value) ? $value : [$value] as $child) {
+ if (! $child instanceof Node) {
+ continue;
+ }
+
+ $this->visit($child, $node, $childNameType, $childInBody, $childInImplements, $childMethodBody);
+ }
+ }
+ }
+
+ /**
+ * Remembers the short names that already refer to something without being imported.
+ */
+ private function reserve(Name $name, int $type): void
+ {
+ if ($name->isFullyQualified() || $name->isRelative() || $name->isSpecialClassName()) {
+ return;
+ }
+
+ $first = $name->getFirst();
+
+ if ($type !== Stmt\Use_::TYPE_NORMAL && $name->isUnqualified()) {
+ if ($type === Stmt\Use_::TYPE_FUNCTION && $this->imports->resolve($type, $first) === null) {
+ $this->reservedFunctions[strtolower($first)] = true;
+ }
+
+ return;
+ }
+
+ if ($this->imports->resolve(Stmt\Use_::TYPE_NORMAL, $first) !== null) {
+ return;
+ }
+
+ $resolved = Names::resolve($name);
+
+ if ($resolved !== null && strcasecmp($resolved, $this->imports->resolveClassName($name, $this->namespace)) !== 0) {
+ return;
+ }
+
+ $this->reservedClasses[strtolower($first)] = ltrim($this->namespace . '\\' . $first, '\\');
+ }
+
+ private function replace(Name $name, Name $replacement): void
+ {
+ $replacement->setAttribute('startFilePos', $name->getStartFilePos());
+ $replacement->setAttribute('endFilePos', $name->getEndFilePos());
+
+ $parent = $this->parents[spl_object_id($name)] ?? null;
+
+ if ($parent === null) {
+ return;
+ }
+
+ foreach ($parent->getSubNodeNames() as $subNodeName) {
+ $value = $parent->{$subNodeName};
+
+ if ($value === $name) {
+ $parent->{$subNodeName} = $replacement;
+
+ break;
+ }
+
+ if (is_array($value) && ($key = array_search($name, $value, strict: true)) !== false) {
+ $value[$key] = $replacement;
+ $parent->{$subNodeName} = $value;
+
+ break;
+ }
+ }
+
+ $node = $parent;
+
+ while ($node !== null && Changes::mark($node)) {
+ $node = $this->parents[spl_object_id($node)] ?? null;
+ }
+ }
+
+ private function simplifyClassName(Name $name, bool $inBody, bool $inImplements): ?Name
+ {
+ if ($name->isSpecialClassName()) {
+ return null;
+ }
+
+ $fullyQualifiedName = Names::resolve($name);
+
+ if ($fullyQualifiedName === null) {
+ return null;
+ }
+
+ $resolvesCorrectly = strcasecmp($this->imports->resolveClassName($name, $this->namespace), $fullyQualifiedName) === 0;
+ $shouldSimplify = $name->isFullyQualified()
+ && match (true) {
+ $inBody => $this->simplifyBodies,
+ $inImplements => $this->simplifyImplements,
+ default => true,
+ };
+
+ if ($resolvesCorrectly && ! $shouldSimplify) {
+ return null;
+ }
+
+ $alias = $this->importClass($fullyQualifiedName);
+
+ if ($alias === null) {
+ return $name->isFullyQualified() ? null : new Name\FullyQualified($fullyQualifiedName);
+ }
+
+ return new Name($alias, [Names::RESOLVED_NAME_ATTRIBUTE => new Name\FullyQualified($fullyQualifiedName)]);
+ }
+
+ private function simplifyFunctionName(Name $name, bool $inBody): ?Name
+ {
+ if (! $name->isFullyQualified()) {
+ return $this->qualifyBrokenName($name, Stmt\Use_::TYPE_FUNCTION);
+ }
+
+ if ($inBody && ! $this->simplifyBodies) {
+ return null;
+ }
+
+ $fullyQualifiedName = $name->toString();
+
+ if (Names::namespaceOf($fullyQualifiedName) === '') {
+ return null;
+ }
+
+ $alias = $this->imports->aliasOf(Stmt\Use_::TYPE_FUNCTION, $fullyQualifiedName);
+
+ if ($alias === null) {
+ $alias = Names::lastSegment($fullyQualifiedName);
+
+ if ($this->imports->resolve(Stmt\Use_::TYPE_FUNCTION, $alias) !== null || isset($this->reservedFunctions[strtolower($alias)])) {
+ return null;
+ }
+
+ $this->imports->add(Stmt\Use_::TYPE_FUNCTION, $fullyQualifiedName);
+ }
+
+ return new Name($alias);
+ }
+
+ /**
+ * Fully qualifies a function or constant name that no longer refers to the same thing, for example after the namespace changed.
+ */
+ private function qualifyBrokenName(Name $name, int $type): ?Name
+ {
+ $fullyQualifiedName = $name->isFullyQualified() ? null : Names::resolve($name);
+
+ if ($fullyQualifiedName === null) {
+ return null;
+ }
+
+ $resolvedName = $name->isUnqualified()
+ ? $this->imports->resolve($type, $name->toString())
+ : $this->imports->resolveClassName($name, $this->namespace);
+
+ if ($resolvedName !== null && strcasecmp($resolvedName, $fullyQualifiedName) === 0) {
+ return null;
+ }
+
+ return new Name\FullyQualified($fullyQualifiedName);
+ }
+
+ /**
+ * Returns the name a class can be referred to with, importing it when needed.
+ */
+ private function importClass(string $fullyQualifiedName): ?string
+ {
+ $alias = $this->imports->aliasOf(Stmt\Use_::TYPE_NORMAL, $fullyQualifiedName);
+
+ if ($alias !== null) {
+ return $alias;
+ }
+
+ $shortName = Names::lastSegment($fullyQualifiedName);
+
+ if ($this->imports->resolve(Stmt\Use_::TYPE_NORMAL, $shortName) !== null) {
+ return null;
+ }
+
+ if (strcasecmp(Names::namespaceOf($fullyQualifiedName), $this->namespace ?? '') === 0) {
+ $this->reservedClasses[strtolower($shortName)] = $fullyQualifiedName;
+
+ return $shortName;
+ }
+
+ $reservedClass = $this->reservedClasses[strtolower($shortName)] ?? null;
+
+ if ($reservedClass !== null && strcasecmp($reservedClass, $fullyQualifiedName) !== 0) {
+ return null;
+ }
+
+ $this->imports->add(Stmt\Use_::TYPE_NORMAL, $fullyQualifiedName);
+
+ return $shortName;
+ }
+}
diff --git a/packages/generation/src/Php/Names.php b/packages/generation/src/Php/Names.php
new file mode 100644
index 0000000000..6bc848ee55
--- /dev/null
+++ b/packages/generation/src/Php/Names.php
@@ -0,0 +1,48 @@
+isFullyQualified()) {
+ return $name->toString();
+ }
+
+ $resolved = $name->getAttribute(self::RESOLVED_NAME_ATTRIBUTE);
+
+ return $resolved instanceof Name ? $resolved->toString() : null;
+ }
+
+ public static function refersTo(Name $name, string $fullyQualifiedName): bool
+ {
+ return strcasecmp(self::resolve($name) ?? $name->toString(), ltrim($fullyQualifiedName, '\\')) === 0;
+ }
+
+ public static function lastSegment(string $name): string
+ {
+ $position = strrpos($name, '\\');
+
+ return $position === false ? $name : substr($name, $position + 1);
+ }
+
+ public static function namespaceOf(string $name): string
+ {
+ $position = strrpos($name, '\\');
+
+ return $position === false ? '' : substr($name, 0, $position);
+ }
+}
diff --git a/packages/generation/src/Php/PhpClass.php b/packages/generation/src/Php/PhpClass.php
new file mode 100644
index 0000000000..2084cd91ed
--- /dev/null
+++ b/packages/generation/src/Php/PhpClass.php
@@ -0,0 +1,171 @@
+node->name->toString();
+ }
+
+ public function setName(string $name): self
+ {
+ $this->node->name = new Identifier($name);
+
+ return $this;
+ }
+
+ public function setFinal(bool $final = true): self
+ {
+ return $this->setModifier(Modifiers::FINAL, $final);
+ }
+
+ public function setReadOnly(bool $readonly = true): self
+ {
+ return $this->setModifier(Modifiers::READONLY, $readonly);
+ }
+
+ public function setAbstract(bool $abstract = true): self
+ {
+ return $this->setModifier(Modifiers::ABSTRACT, $abstract);
+ }
+
+ public function setExtends(?string $class): self
+ {
+ $this->node->extends = $class !== null ? new Name\FullyQualified(ltrim($class, '\\')) : null;
+
+ return $this;
+ }
+
+ public function addImplement(string $interface): self
+ {
+ if (array_any($this->node->implements, static fn (Name $implement) => Names::refersTo($implement, $interface))) {
+ return $this;
+ }
+
+ $this->node->implements[] = new Name\FullyQualified(ltrim($interface, '\\'));
+
+ return $this;
+ }
+
+ public function addTrait(string $trait): self
+ {
+ $traitUse = new Stmt\TraitUse([new Name\FullyQualified(ltrim($trait, '\\'))]);
+ $lastTraitUse = null;
+
+ foreach ($this->node->stmts as $index => $stmt) {
+ if (! $stmt instanceof Stmt\TraitUse) {
+ continue;
+ }
+
+ $lastTraitUse = $index;
+ }
+
+ if ($lastTraitUse !== null) {
+ array_splice($this->node->stmts, $lastTraitUse + 1, 0, [$traitUse]);
+ } else {
+ array_splice($this->node->stmts, 0, 0, $this->node->stmts === [] ? [$traitUse] : [$traitUse, Printer::blankLine()]);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param array $arguments
+ */
+ public function addAttribute(string $name, array $arguments = []): self
+ {
+ Attributes::add($this->node, $name, $arguments);
+
+ return $this;
+ }
+
+ public function removeAttribute(string $name): self
+ {
+ Attributes::remove($this->node, $name);
+
+ return $this;
+ }
+
+ public function setComment(?string $comment): self
+ {
+ $this->node->setAttribute('comments', $comment !== null ? [DocComment::from($comment)] : []);
+
+ return $this;
+ }
+
+ public function hasMethod(string $name): bool
+ {
+ return $this->findMethod($name) !== null;
+ }
+
+ public function getMethod(string $name): PhpMethod
+ {
+ $method = $this->findMethod($name) ?? throw new MethodWasMissing($this->getName(), $name);
+
+ return new PhpMethod($method);
+ }
+
+ /**
+ * Adds a public method, replacing any existing method with the same name.
+ */
+ public function addMethod(string $name): PhpMethod
+ {
+ $method = new Stmt\ClassMethod($name, ['flags' => Modifiers::PUBLIC, 'stmts' => []]);
+ $index = $this->findMethodIndex($name);
+
+ if ($index !== null) {
+ $this->node->stmts[$index] = $method;
+ } else {
+ array_push($this->node->stmts, ...$this->node->stmts === [] ? [$method] : [Printer::blankLine(), $method]);
+ }
+
+ return new PhpMethod($method);
+ }
+
+ public function removeMethod(string $name): self
+ {
+ $index = $this->findMethodIndex($name);
+
+ if ($index !== null) {
+ array_splice($this->node->stmts, $index, 1);
+ }
+
+ return $this;
+ }
+
+ private function setModifier(int $modifier, bool $enabled): self
+ {
+ $this->node->flags = $enabled
+ ? $this->node->flags | $modifier
+ : $this->node->flags & ~$modifier;
+
+ return $this;
+ }
+
+ private function findMethod(string $name): ?Stmt\ClassMethod
+ {
+ return array_find($this->node->getMethods(), static fn (Stmt\ClassMethod $method) => $method->name->toLowerString() === strtolower($name));
+ }
+
+ private function findMethodIndex(string $name): ?int
+ {
+ return array_find_key($this->node->stmts, static fn (Stmt $stmt) => $stmt instanceof Stmt\ClassMethod && $stmt->name->toLowerString() === strtolower($name));
+ }
+}
diff --git a/packages/generation/src/Php/PhpFile.php b/packages/generation/src/Php/PhpFile.php
new file mode 100644
index 0000000000..2baaa960be
--- /dev/null
+++ b/packages/generation/src/Php/PhpFile.php
@@ -0,0 +1,340 @@
+ $importsUsedOnLoad
+ */
+ private function __construct(
+ private array $stmts,
+ private readonly ?array $originalStmts = null,
+ private readonly ?array $tokens = null,
+ private readonly array $importsUsedOnLoad = [],
+ ) {}
+
+ public static function create(?string $namespace = null): self
+ {
+ return new self($namespace ? [new Stmt\Namespace_(new Name($namespace))] : []);
+ }
+
+ public static function fromCode(string $code): self
+ {
+ [$originalStmts, $tokens] = SourceParser::parse($code);
+
+ $cloner = new Cloner();
+ $stmts = $cloner->cloneStatements($originalStmts);
+
+ return new self($stmts, $originalStmts, $tokens, $cloner->importKeys);
+ }
+
+ public static function fromPath(string $path): self
+ {
+ return self::fromCode(Filesystem\read_file($path));
+ }
+
+ public function getNamespace(): ?string
+ {
+ return $this->findNamespace()?->name?->toString();
+ }
+
+ public function setNamespace(string $namespace): self
+ {
+ $existing = $this->findNamespace();
+
+ if ($existing !== null) {
+ $existing->name = new Name($namespace);
+
+ return $this;
+ }
+
+ $declares = array_filter($this->stmts, static fn (Stmt $stmt) => $stmt instanceof Stmt\Declare_);
+ $stmts = array_filter($this->stmts, static fn (Stmt $stmt) => ! $stmt instanceof Stmt\Declare_);
+
+ $this->stmts = [...$declares, new Stmt\Namespace_(new Name($namespace), array_values($stmts))];
+
+ return $this;
+ }
+
+ public function hasStrictTypes(): bool
+ {
+ $declaration = $this->findStrictTypesDeclaration();
+
+ return $declaration?->value instanceof Int_ && $declaration->value->value === 1;
+ }
+
+ public function setStrictTypes(bool $strictTypes = true): self
+ {
+ $declaration = $this->findStrictTypesDeclaration();
+
+ if ($strictTypes && $declaration !== null) {
+ $declaration->value = new Int_(1);
+ } elseif ($strictTypes) {
+ $position = $this->hasComment() ? 1 : 0;
+
+ array_splice($this->stmts, $position, 0, [
+ new Stmt\Declare_([new DeclareItem('strict_types', new Int_(1))]),
+ Printer::blankLine(),
+ ]);
+ } elseif ($declaration !== null) {
+ $this->removeDeclaration($declaration);
+ }
+
+ return $this;
+ }
+
+ public function setComment(?string $comment): self
+ {
+ if ($this->hasComment()) {
+ array_shift($this->stmts);
+ }
+
+ if ($comment !== null) {
+ array_unshift($this->stmts, new Stmt\Nop(['comments' => [DocComment::from($comment)]]));
+ }
+
+ return $this;
+ }
+
+ /**
+ * Imports a class, using the given alias to refer to it.
+ */
+ public function addUse(string $name, ?string $alias = null): self
+ {
+ $stmts = &$this->body();
+
+ $imports = Imports::from($stmts);
+ $imports->add(Stmt\Use_::TYPE_NORMAL, ltrim($name, '\\'), $alias);
+ $imports->insertInto($stmts);
+
+ return $this;
+ }
+
+ public function hasClass(?string $name = null): bool
+ {
+ return $this->findClass($name) !== null;
+ }
+
+ public function getClass(?string $name = null): PhpClass
+ {
+ return new PhpClass($this->findClass($name) ?? throw new ClassWasMissing($name));
+ }
+
+ public function addClass(string $name): PhpClass
+ {
+ $class = new Stmt\Class_($name);
+ $stmts = &$this->body();
+
+ array_push($stmts, ...$stmts === [] ? [$class] : [Printer::blankLine(), $class]);
+
+ return new PhpClass($class);
+ }
+
+ /**
+ * Whether fully qualified names of implemented interfaces are imported.
+ */
+ public function simplifyImplements(bool $simplify = true): self
+ {
+ $this->simplifyImplements = $simplify;
+
+ return $this;
+ }
+
+ /**
+ * Whether fully qualified names in method bodies are imported.
+ */
+ public function simplifyNamesInBodies(bool $simplify = true): self
+ {
+ $this->simplifyNamesInBodies = $simplify;
+
+ return $this;
+ }
+
+ public function print(): string
+ {
+ $this->resolveNames();
+
+ self::$printer ??= new Printer();
+
+ $originalStmts = $this->originalStmts;
+ $tokens = $this->tokens;
+
+ if ($originalStmts !== null && $tokens !== null) {
+ return self::$printer->printChangedFile($this->stmts, $originalStmts, $tokens);
+ }
+
+ return self::$printer->printNewFile($this->stmts);
+ }
+
+ private function resolveNames(): void
+ {
+ $imports = Imports::from($this->body());
+
+ $simplifier = new NameSimplifier(
+ namespace: $this->getNamespace(),
+ imports: $imports,
+ declaredClasses: $this->declaredClasses(),
+ simplifyImplements: $this->simplifyImplements,
+ simplifyBodies: $this->simplifyNamesInBodies,
+ );
+
+ $simplifier->simplify($this->stmts);
+
+ $stmts = &$this->body();
+
+ $imports->insertInto($stmts);
+
+ if (Imports::hasUnused($stmts, $simplifier->usedImports)) {
+ Imports::removeUnused($stmts, $this->importsUsedOnLoad, $simplifier->usedImports, $this->comments());
+ }
+ }
+
+ /**
+ * @return Stmt[]
+ */
+ private function &body(): array
+ {
+ foreach ($this->stmts as $stmt) {
+ if ($stmt instanceof Stmt\Namespace_) {
+ return $stmt->stmts;
+ }
+ }
+
+ return $this->stmts;
+ }
+
+ private function findNamespace(): ?Stmt\Namespace_
+ {
+ return array_find($this->stmts, static fn (Stmt $stmt) => $stmt instanceof Stmt\Namespace_);
+ }
+
+ private function findClass(?string $name): ?Stmt\Class_
+ {
+ foreach ($this->body() as $stmt) {
+ if (! $stmt instanceof Stmt\Class_ || $stmt->name === null) {
+ continue;
+ }
+
+ if ($name === null || $stmt->name->toLowerString() === strtolower($name)) {
+ return $stmt;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @return array
+ */
+ private function declaredClasses(): array
+ {
+ $classes = [];
+
+ foreach ($this->body() as $stmt) {
+ if (! $stmt instanceof Stmt\ClassLike || $stmt->name === null) {
+ continue;
+ }
+
+ $classes[$stmt->name->toLowerString()] = true;
+ }
+
+ return $classes;
+ }
+
+ private function findStrictTypesDeclaration(): ?DeclareItem
+ {
+ foreach ($this->stmts as $stmt) {
+ if (! $stmt instanceof Stmt\Declare_) {
+ continue;
+ }
+
+ foreach ($stmt->declares as $declare) {
+ if ($declare->key->toLowerString() === 'strict_types') {
+ return $declare;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private function removeDeclaration(DeclareItem $declaration): void
+ {
+ foreach ($this->stmts as $index => $stmt) {
+ if (! $stmt instanceof Stmt\Declare_ || ! in_array($declaration, $stmt->declares, strict: true)) {
+ continue;
+ }
+
+ $stmt->declares = array_values(array_filter($stmt->declares, static fn (DeclareItem $item) => $item !== $declaration));
+
+ if ($stmt->declares === []) {
+ array_splice($this->stmts, $index, 1);
+ }
+
+ return;
+ }
+ }
+
+ /**
+ * The comments in the original code, and the ones that were added to the file, its classes, or method bodies.
+ */
+ private function comments(): string
+ {
+ $comments = '';
+
+ foreach ($this->tokens ?? [] as $token) {
+ if (! $token->is([T_COMMENT, T_DOC_COMMENT])) {
+ continue;
+ }
+
+ $comments .= $token->text . "\n";
+ }
+
+ foreach ([...$this->stmts, ...$this->body()] as $stmt) {
+ foreach ($stmt->getComments() as $comment) {
+ $comments .= $comment->getText() . "\n";
+ }
+
+ if (! $stmt instanceof Stmt\Class_) {
+ continue;
+ }
+
+ foreach ($stmt->getMethods() as $method) {
+ $comments .= $method->getAttribute(MethodBody::ATTRIBUTE)?->comments();
+ }
+ }
+
+ return $comments;
+ }
+
+ private function hasComment(): bool
+ {
+ $first = $this->stmts[0] ?? null;
+
+ return $first instanceof Stmt\Nop && ! Printer::isBlankLine($first) && $first->getComments() !== [];
+ }
+}
diff --git a/packages/generation/src/Php/PhpMethod.php b/packages/generation/src/Php/PhpMethod.php
new file mode 100644
index 0000000000..7f34a56e57
--- /dev/null
+++ b/packages/generation/src/Php/PhpMethod.php
@@ -0,0 +1,69 @@
+node->name->toString();
+ }
+
+ public function setReturnType(?string $type): self
+ {
+ $this->node->returnType = $type !== null ? TypeParser::parse($type) : null;
+
+ return $this;
+ }
+
+ public function addParameter(string $name, ?string $type = null): self
+ {
+ $this->node->params[] = new Param(
+ var: new Variable(ltrim($name, '$')),
+ type: $type !== null ? TypeParser::parse($type) : null,
+ );
+
+ return $this;
+ }
+
+ public function setBody(string $code): self
+ {
+ [$body, $stmts] = MethodBody::parse($code);
+
+ $this->node->stmts = $stmts;
+ $this->node->setAttribute(MethodBody::ATTRIBUTE, $body);
+ $this->node->setAttribute('origNode', null);
+
+ return $this;
+ }
+
+ /**
+ * @param array $arguments
+ */
+ public function addAttribute(string $name, array $arguments = []): self
+ {
+ Attributes::add($this->node, $name, $arguments);
+
+ return $this;
+ }
+
+ public function removeAttribute(string $name): self
+ {
+ Attributes::remove($this->node, $name);
+
+ return $this;
+ }
+}
diff --git a/packages/generation/src/Php/Printer.php b/packages/generation/src/Php/Printer.php
new file mode 100644
index 0000000000..56543998db
--- /dev/null
+++ b/packages/generation/src/Php/Printer.php
@@ -0,0 +1,284 @@
+ PhpVersion::fromComponents(8, 5),
+ 'shortArraySyntax' => true,
+ ]);
+ }
+
+ /**
+ * A statement that separates the surrounding statements by a blank line when they are inserted into existing code.
+ */
+ public static function blankLine(): Stmt\Nop
+ {
+ return new Stmt\Nop([self::BLANK_LINE_ATTRIBUTE => true]);
+ }
+
+ public static function isBlankLine(Node $node): bool
+ {
+ return $node instanceof Stmt\Nop && $node->getAttribute(self::BLANK_LINE_ATTRIBUTE) === true;
+ }
+
+ /**
+ * @param Stmt[] $stmts
+ */
+ public function printNewFile(array $stmts): string
+ {
+ $this->resetState();
+ $this->preprocessNodes($stmts);
+
+ return 'newline . $this->newline . $this->pBlocks($stmts) . $this->newline;
+ }
+
+ /**
+ * @param Stmt[] $stmts
+ * @param Stmt[] $originalStmts
+ * @param Token[] $tokens
+ */
+ public function printChangedFile(array $stmts, array $originalStmts, array $tokens): string
+ {
+ $code = $this->printFormatPreserving($stmts, $originalStmts, $tokens);
+ $code = preg_replace('/[ \t]*' . self::BLANK_LINE_MARKER . '/', '', $code);
+
+ return str_ends_with($code, $this->newline) ? $code : $code . $this->newline;
+ }
+
+ #[Override]
+ protected function p(Node $node, int $precedence = self::MAX_PRECEDENCE, int $lhsPrecedence = self::MAX_PRECEDENCE, bool $parentFormatPreserved = false): string
+ {
+ if ($this->origTokens === null) {
+ return $this->{'p' . $node->getType()}($node, $precedence, $lhsPrecedence);
+ }
+
+ $original = $node->getAttribute('origNode');
+ $canBeCopied = $parentFormatPreserved || ! $node instanceof Stmt\InlineHTML;
+
+ if ($original instanceof Node && $canBeCopied && Changes::isUnchanged($node)) {
+ $start = $original->getStartTokenPos();
+ $indentAdjustment = $this->indentLevel - $this->origTokens->getIndentationBefore($start);
+
+ return $this->origTokens->getTokenCode($start, $original->getEndTokenPos() + 1, $indentAdjustment);
+ }
+
+ return parent::p($node, $precedence, $lhsPrecedence, $parentFormatPreserved);
+ }
+
+ #[Override]
+ protected function pArray(array $nodes, array $origNodes, int &$pos, int $indentAdjustment, string $parentNodeClass, string $subNodeName, ?int $fixup): ?string
+ {
+ if ($subNodeName !== 'attrGroups' || $nodes !== [] || $origNodes === []) {
+ return parent::pArray($nodes, $origNodes, $pos, $indentAdjustment, $parentNodeClass, $subNodeName, $fixup);
+ }
+
+ $code = $this->origTokens->getTokenCode($pos, $origNodes[0]->getStartTokenPos(), $indentAdjustment);
+ $pos = $this->origTokens->skipRightWhitespace($origNodes[array_key_last($origNodes)]->getEndTokenPos() + 1);
+
+ return $code;
+ }
+
+ #[Override]
+ protected function pStmt_Nop(Stmt\Nop $node): string
+ {
+ return self::isBlankLine($node) ? self::BLANK_LINE_MARKER : '';
+ }
+
+ #[Override]
+ protected function pStmt_Declare(Stmt\Declare_ $node): string
+ {
+ $stmts = $node->stmts;
+
+ return 'declare(' . $this->pCommaSeparated($node->declares) . ')' . ($stmts !== null ? ' {' . $this->pStmts($stmts) . $this->nl . '}' : ';');
+ }
+
+ #[Override]
+ protected function pStmt_Namespace(Stmt\Namespace_ $node): string
+ {
+ $name = $node->name;
+
+ if (! $this->canUseSemicolonNamespaces || $name === null) {
+ return parent::pStmt_Namespace($node);
+ }
+
+ $stmts = $this->pBlocks($node->stmts);
+
+ return 'namespace ' . $this->p($name) . ';' . ($stmts !== '' ? $this->newline . $this->nl . $stmts : '');
+ }
+
+ #[Override]
+ protected function pClassCommon(Stmt\Class_ $node, string $afterClassToken): string
+ {
+ $extends = $node->extends;
+ $declaration =
+ $this->pAttrGroups($node->attrGroups, $node->name === null)
+ . $this->pModifiers($node->flags)
+ . 'class'
+ . $afterClassToken
+ . ($extends !== null ? ' extends ' . $this->p($extends) : '')
+ . ($node->implements !== [] ? ' implements ' . $this->pCommaSeparated($node->implements) : '');
+
+ $this->indent();
+ $members = $this->pBlocks($node->stmts);
+ $this->outdent();
+
+ if ($members === '') {
+ return $declaration . ' {}';
+ }
+
+ return $declaration . $this->nl . '{' . $this->newline . $this->indentation(1) . $members . $this->nl . '}';
+ }
+
+ #[Override]
+ protected function pStmt_ClassMethod(Stmt\ClassMethod $node): string
+ {
+ $multiline = $this->shouldBreakParameters($node->params);
+ $returnType = $node->returnType;
+
+ $signature =
+ $this->pAttrGroups($node->attrGroups)
+ . $this->pModifiers($node->flags)
+ . 'function '
+ . ($node->byRef ? '&' : '')
+ . $node->name
+ . '('
+ . $this->pParams($node->params)
+ . ')'
+ . ($returnType !== null ? ': ' . $this->p($returnType) : '');
+
+ if ($node->stmts === null) {
+ return $signature . ';';
+ }
+
+ $body = $this->pMethodBody($node);
+
+ if ($body === '') {
+ return $signature . ' {}';
+ }
+
+ return $signature . ($multiline ? ' {' : $this->nl . '{') . $body . $this->nl . '}';
+ }
+
+ #[Override]
+ protected function pParams(array $params): string
+ {
+ if (! $this->shouldBreakParameters($params)) {
+ return $this->pCommaSeparated($params);
+ }
+
+ $wasMultiline = $this->multilineParameters;
+ $this->multilineParameters = true;
+ $code = $this->pCommaSeparatedMultiline($params, trailingComma: true) . $this->nl;
+ $this->multilineParameters = $wasMultiline;
+
+ return $code;
+ }
+
+ #[Override]
+ protected function pParam(Node\Param $node): string
+ {
+ if (! $this->multilineParameters || $node->attrGroups === []) {
+ return parent::pParam($node);
+ }
+
+ $attributes = '';
+
+ foreach ($node->attrGroups as $group) {
+ $attributes .= $this->p($group) . $this->nl;
+ }
+
+ return $attributes . parent::pParam(clone($node, ['attrGroups' => []]));
+ }
+
+ /**
+ * Prints statements separated by blank lines, keeping related statements such as imports together.
+ *
+ * @param Node[] $nodes
+ */
+ private function pBlocks(array $nodes): string
+ {
+ $code = '';
+ $previous = null;
+
+ foreach ($nodes as $node) {
+ if (self::isBlankLine($node)) {
+ continue;
+ }
+
+ if ($previous !== null) {
+ $code .= $this->belongTogether($previous, $node) ? $this->nl : $this->newline . $this->nl;
+ }
+
+ $comments = $node->getComments();
+
+ if ($node instanceof Stmt\Nop) {
+ $code .= $this->pComments($comments);
+ } else {
+ $code .= ($comments !== [] ? $this->pComments($comments) . $this->nl : '') . $this->p($node);
+ }
+
+ $previous = $node;
+ }
+
+ return $code;
+ }
+
+ private function belongTogether(Node $previous, Node $node): bool
+ {
+ if ($previous instanceof Stmt\Use_ && $node instanceof Stmt\Use_) {
+ return $previous->type === $node->type;
+ }
+
+ return $previous::class === $node::class && ($node instanceof Stmt\TraitUse || $node instanceof Stmt\EnumCase);
+ }
+
+ private function pMethodBody(Stmt\ClassMethod $node): string
+ {
+ $methodBody = $node->getAttribute(MethodBody::ATTRIBUTE);
+
+ if (! $methodBody instanceof MethodBody) {
+ return $this->pStmts($node->stmts ?? []);
+ }
+
+ $code = $methodBody->print($this->indentation(1));
+
+ return $code !== '' ? $this->newline . $code : '';
+ }
+
+ /**
+ * @param Node\Param[] $params
+ */
+ private function shouldBreakParameters(array $params): bool
+ {
+ return array_any($params, static fn (Node\Param $param) => $param->flags !== 0 || $param->attrGroups !== [] || $param->getComments() !== []);
+ }
+
+ private function indentation(int $additionalLevels): string
+ {
+ return substr($this->nl, strlen($this->newline)) . str_repeat(' ', $additionalLevels);
+ }
+}
diff --git a/packages/generation/src/Php/SimplifiesClassNames.php b/packages/generation/src/Php/SimplifiesClassNames.php
deleted file mode 100644
index 9843a89369..0000000000
--- a/packages/generation/src/Php/SimplifiesClassNames.php
+++ /dev/null
@@ -1,164 +0,0 @@
-simplifyClassNamesInBodies = $simplify;
-
- return $this;
- }
-
- public function simplifyImplements(bool $simplify = true): self
- {
- $this->simplifyImplements = $simplify;
-
- return $this;
- }
-
- public function addUse(string $use): self
- {
- $this->aliases[$use] = null;
-
- return $this;
- }
-
- public function setAlias(string $class, string $alias): self
- {
- if (isset($this->aliases[$class])) {
- unset($this->aliases[$class]);
- }
-
- $this->aliases[$class] = $alias;
-
- return $this;
- }
-
- private function simplifyClassNames(PhpFile $file): PhpFile
- {
- foreach ($file->getNamespaces() as $namespace) {
- foreach ($namespace->getClasses() as $class) {
- $types = [];
- $functions = [];
-
- if ($this->simplifyImplements) {
- foreach ($class->getImplements() as $implement) {
- $types[] = $implement;
- }
- }
-
- foreach ($class->getAttributes() as $attribute) {
- $types[] = $attribute->getName();
- }
-
- foreach ($class->getTraits() as $trait) {
- $types[] = $trait->getName();
- }
-
- foreach ($class->getMethods() as $method) {
- $types[] = $method->getReturnType(true);
-
- foreach ($method->getParameters() as $parameter) {
- $types[] = $parameter->getType(true);
-
- foreach ($parameter->getAttributes() as $attribute) {
- $types[] = $attribute->getName();
- }
- }
-
- foreach ($method->getAttributes() as $attribute) {
- $types[] = $attribute->getName();
- }
-
- if ($this->simplifyClassNamesInBodies) {
- $methodBody = $method->getBody();
- $fqcnMatches = $this->extractFqcnFromBody($methodBody);
-
- foreach (array_filter($fqcnMatches) as $fqcn) {
- if (str_contains($methodBody, "/*(f*/\\{$fqcn}")) {
- $namespace->addUseFunction($fqcn);
-
- continue;
- }
-
- if (str_contains($methodBody, "/*(n*/\\{$fqcn}")) {
- $namespace->addUse($fqcn);
-
- continue;
- }
-
- $methodBody = str_replace(
- search: '\\' . $fqcn,
- replace: (string) str($fqcn)->afterLast('\\'),
- subject: $methodBody,
- );
-
- $namespace->addUse($fqcn);
- }
-
- $method->setBody($methodBody);
- }
- }
-
- array_map(
- function ($param) use (&$types): void {
- $types[] = $param->getType(true);
- },
- $class->getProperties(),
- );
-
- foreach ($this->aliases as $class => $alias) {
- $namespace->addUse($class, $alias);
- }
-
- foreach (array_filter($types) as $type) {
- if (is_string($type)) {
- $namespace->addUse($type);
-
- continue;
- }
-
- foreach ($type->getTypes() as $subtype) {
- if (! $subtype->isClass()) {
- continue;
- }
-
- if ($subtype->isClassKeyword()) {
- continue;
- }
-
- $namespace->addUse((string) $subtype);
- }
- }
- }
- }
-
- return $file;
- }
-
- private function extractFqcnFromBody(string $body): array
- {
- preg_match_all('/(?:\\\\?[A-Za-z_][\w\d_]*\\\\)+[A-Za-z_][\w\d_]*/', $body, $matches);
-
- return array_filter(array_unique(
- array_map(fn (string $fqcn) => rtrim(ltrim($fqcn, '\\'), ':'), $matches[0]),
- ));
- }
-}
diff --git a/packages/generation/src/Php/SourceParser.php b/packages/generation/src/Php/SourceParser.php
new file mode 100644
index 0000000000..6eca336fd6
--- /dev/null
+++ b/packages/generation/src/Php/SourceParser.php
@@ -0,0 +1,49 @@
+createForVersion(PhpVersion::fromComponents(8, 5));
+
+ try {
+ $stmts = self::$parser->parse($code) ?? [];
+ } catch (Error $error) {
+ throw new PhpCodeWasInvalid($error);
+ }
+
+ return [$stmts, self::$parser->getTokens()];
+ }
+
+ public static function parseExpression(string $code): Expr
+ {
+ [$stmts] = self::parse("expr;
+ }
+}
diff --git a/packages/generation/src/Php/StubFileGenerator.php b/packages/generation/src/Php/StubFileGenerator.php
index b2db010057..81b3f946e8 100644
--- a/packages/generation/src/Php/StubFileGenerator.php
+++ b/packages/generation/src/Php/StubFileGenerator.php
@@ -26,7 +26,7 @@ final class StubFileGenerator
* @param StubFile $stubFile The stub file to use for the generation. It must be of type CLASS_FILE.
* @param string $targetPath The path where the generated file will be saved including the filename and extension.
* @param bool $shouldOverride Whether the generator should override the file if it already exists.
- * @param array $replacements An array of key-value pairs to replace in the stub file.
+ * @param array $replacements An array of key-value pairs to replace in the stub file.
* The keys are the placeholders in the stub file (e.g. 'DummyNamespace')
* The values are the replacements for the placeholders (e.g. 'App\Models')
*
@@ -46,35 +46,37 @@ public function generateClassFile(
throw new FileGenerationFailedException(sprintf('The stub file must be of type CLASS_FILE, %s given.', $stubFile->type->name));
}
- if (Filesystem\is_file($targetPath) && ! $shouldOverride) {
+ if (! $shouldOverride && Filesystem\is_file($targetPath)) {
throw new FileGenerationWasAborted(sprintf('The file %s already exists and the operation has been aborted.', $targetPath));
}
$this->prepareFilesystem($targetPath);
// Transform stub to class
- $namespace = registered_namespace($targetPath);
- $classname = to_base_class_name($targetPath);
- $classManipulator = new ClassManipulator($stubFile->filePath)
- ->setNamespace($namespace)
- ->setClassName($classname);
+ $code = Filesystem\read_file($stubFile->filePath);
foreach ($replacements as $placeholder => $replacement) {
- if (! is_string($replacement)) {
+ if ($replacement === null) {
continue;
}
- $classManipulator->manipulate(fn (ImmutableString $code) => $code->replace($placeholder, $replacement));
+ $code = str_replace($placeholder, $replacement, $code);
}
+ $namespace = registered_namespace($targetPath);
+ $classname = to_base_class_name($targetPath);
+ $classManipulator = new ClassManipulator(PhpFile::fromCode($code))
+ ->setNamespace($namespace)
+ ->setClassName($classname);
+
// Run all manipulations
$classManipulator = array_reduce(
array: $manipulations,
- callback: fn (ClassManipulator $manipulator, Closure $manipulation) => $manipulation($manipulator),
+ callback: static fn (ClassManipulator $manipulator, Closure $manipulation) => $manipulation($manipulator),
initial: $classManipulator,
);
- if (Filesystem\is_file($targetPath) && $shouldOverride) {
+ if ($shouldOverride && Filesystem\is_file($targetPath)) {
Filesystem\delete_file($targetPath);
}
@@ -88,7 +90,7 @@ public function generateClassFile(
* @param StubFile $stubFile The stub file to use for the generation. It must be of type RAW_FILE.
* @param string $targetPath The path where the generated file will be saved including the filename and extension.
* @param bool $shouldOverride Whether the generator should override the file if it already exists.
- * @param array $replacements An array of key-value pairs to replace in the stub file.
+ * @param array $replacements An array of key-value pairs to replace in the stub file.
* The keys are the placeholders in the stub file (e.g. 'dummy-content')
* The values are the replacements for the placeholders (e.g. 'real content')
*
@@ -108,7 +110,7 @@ public function generateRawFile(
throw new FileGenerationFailedException(sprintf('The stub file must be of type RAW_FILE, "%s" given.', $stubFile->type->name));
}
- if (Filesystem\is_file($targetPath) && ! $shouldOverride) {
+ if (! $shouldOverride && Filesystem\is_file($targetPath)) {
throw new FileGenerationWasAborted(sprintf('The file "%s" already exists and the operation has been aborted.', $targetPath));
}
@@ -116,7 +118,7 @@ public function generateRawFile(
$fileContent = Filesystem\read_file($stubFile->filePath);
foreach ($replacements as $placeholder => $replacement) {
- if (! is_string($replacement)) {
+ if ($replacement === null) {
continue;
}
@@ -126,11 +128,11 @@ public function generateRawFile(
// Run all manipulations
$fileContent = array_reduce(
array: $manipulations,
- callback: fn (ImmutableString $content, Closure $manipulation) => $manipulation($content),
+ callback: static fn (ImmutableString $content, Closure $manipulation) => $manipulation($content),
initial: $fileContent,
);
- if (Filesystem\is_file($targetPath) && $shouldOverride) {
+ if ($shouldOverride && Filesystem\is_file($targetPath)) {
Filesystem\delete_file($targetPath);
}
@@ -148,10 +150,6 @@ public function generateRawFile(
*/
private function prepareFilesystem(string $targetPath): void
{
- // Recursively create directories before writing the file
- $directory = dirname($targetPath);
- if (! is_dir($directory)) {
- mkdir($directory, recursive: true);
- }
+ Filesystem\create_directory_for_file($targetPath);
}
}
diff --git a/packages/generation/src/Php/TypeParser.php b/packages/generation/src/Php/TypeParser.php
new file mode 100644
index 0000000000..ee9c3ec6f8
--- /dev/null
+++ b/packages/generation/src/Php/TypeParser.php
@@ -0,0 +1,102 @@
+ 1) {
+ return new UnionType(array_map(self::parse(...), $parts));
+ }
+
+ if (count($parts = self::split($type, '&')) > 1) {
+ return new IntersectionType(array_map(self::parse(...), $parts));
+ }
+
+ if (str_starts_with($type, '(') && str_ends_with($type, ')')) {
+ return self::parse(substr($type, 1, -1));
+ }
+
+ $lowercased = strtolower($type);
+
+ if (in_array($lowercased, self::BUILTIN_TYPES, strict: true)) {
+ return new Identifier($lowercased);
+ }
+
+ if ($lowercased === 'self' || $lowercased === 'parent') {
+ return new Name($lowercased);
+ }
+
+ return new Name\FullyQualified(ltrim($type, '\\'));
+ }
+
+ /**
+ * @return string[]
+ */
+ private static function split(string $type, string $separator): array
+ {
+ $parts = [];
+ $depth = 0;
+ $current = '';
+
+ foreach (str_split($type) as $character) {
+ $depth += match ($character) {
+ '(' => 1,
+ ')' => -1,
+ default => 0,
+ };
+
+ if ($character === $separator && $depth === 0) {
+ $parts[] = $current;
+ $current = '';
+
+ continue;
+ }
+
+ $current .= $character;
+ }
+
+ $parts[] = $current;
+
+ return $parts;
+ }
+}
diff --git a/packages/generation/src/Php/ValueExporter.php b/packages/generation/src/Php/ValueExporter.php
new file mode 100644
index 0000000000..82cc7ef6f6
--- /dev/null
+++ b/packages/generation/src/Php/ValueExporter.php
@@ -0,0 +1,75 @@
+ SourceParser::parseExpression($value->code),
+ $value instanceof UnitEnum => new Expr\ClassConstFetch(new Name\FullyQualified($value::class), $value->name),
+ $value === null => new Expr\ConstFetch(new Name('null')),
+ is_bool($value) => new Expr\ConstFetch(new Name($value ? 'true' : 'false')),
+ is_int($value) => self::exportInteger($value),
+ is_float($value) => new Scalar\Float_($value),
+ is_string($value) => new Scalar\String_($value),
+ is_array($value) => self::exportArray($value),
+ default => throw new ValueWasNotExportable($value),
+ };
+ }
+
+ /**
+ * @param array $arguments
+ * @return Arg[]
+ */
+ public static function exportArguments(array $arguments): array
+ {
+ $args = [];
+
+ foreach ($arguments as $name => $value) {
+ $args[] = new Arg(self::export($value), name: is_string($name) ? new Identifier($name) : null);
+ }
+
+ return $args;
+ }
+
+ private static function exportInteger(int $value): Expr
+ {
+ if ($value === PHP_INT_MIN) {
+ return new Expr\BinaryOp\Minus(new Expr\UnaryMinus(new Scalar\Int_(PHP_INT_MAX)), new Scalar\Int_(1));
+ }
+
+ return $value < 0 ? new Expr\UnaryMinus(new Scalar\Int_(-$value)) : new Scalar\Int_($value);
+ }
+
+ private static function exportArray(array $values): Expr\Array_
+ {
+ $items = [];
+ $nextIndex = 0;
+
+ foreach ($values as $key => $value) {
+ $items[] = new ArrayItem(self::export($value), $key === $nextIndex ? null : self::export($key));
+
+ if (is_int($key)) {
+ $nextIndex = max($nextIndex, $key + 1);
+ }
+ }
+
+ return new Expr\Array_($items, ['kind' => Expr\Array_::KIND_SHORT]);
+ }
+}
diff --git a/packages/generation/tests/Php/ClassManipulatorTest.php b/packages/generation/tests/Php/ClassManipulatorTest.php
index 3c918215a2..25e7586148 100644
--- a/packages/generation/tests/Php/ClassManipulatorTest.php
+++ b/packages/generation/tests/Php/ClassManipulatorTest.php
@@ -7,7 +7,7 @@
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Tempest\Generation\Php\ClassManipulator;
-use Tempest\Generation\Tests\Php\Fixtures\ClassWithDummyStringToBeReplacedByFqcn;
+use Tempest\Generation\Php\PhpFile;
use Tempest\Generation\Tests\Php\Fixtures\ClassWithMethodParameterAttributes;
use Tempest\Generation\Tests\Php\Fixtures\ClassWithTraitInAnotherNamespace;
use Tempest\Generation\Tests\Php\Fixtures\CreateMigrationsTable;
@@ -15,7 +15,7 @@
use Tempest\Generation\Tests\Php\Fixtures\SampleNamespace\DummyFqcn;
use Tempest\Generation\Tests\Php\Fixtures\TestAttribute;
use Tempest\Generation\Tests\Php\Fixtures\WelcomeController;
-use Tempest\Support\Str\ImmutableString;
+use Tempest\Support\Filesystem;
/**
* @internal
@@ -159,17 +159,6 @@ public function transforms_functions(): void
$this->assertMatchesSnapshot($class->print());
}
- #[Test]
- public function manipulates_classes(): void
- {
- $class = new ClassManipulator(WelcomeController::class);
- $class->setNamespace('App\\Controllers');
- $class->setClassName('WelcomeController');
- $class->manipulate(fn (ImmutableString $string) => $string->replace('welcome', 'home'));
-
- $this->assertMatchesSnapshot($class->print());
- }
-
#[Test]
public function simplifies_traits_from_another_namespace(): void
{
@@ -187,10 +176,15 @@ public function simplifies_method_parameter_attributes(): void
}
#[Test]
- public function make_replacements_before_converting_fqcn(): void
+ public function simplifies_fully_qualified_names_in_loaded_code(): void
{
- $class = new ClassManipulator(ClassWithDummyStringToBeReplacedByFqcn::class);
- $class->manipulate(fn (ImmutableString $string) => $string->replace("'fqcn-to-be-replaced'", sprintf('%s::class', DummyFqcn::class)));
+ $code = str_replace(
+ "'fqcn-to-be-replaced'",
+ sprintf('\\%s::class', DummyFqcn::class),
+ Filesystem\read_file(__DIR__ . '/Fixtures/ClassWithDummyStringToBeReplacedByFqcn.php'),
+ );
+
+ $class = new ClassManipulator(PhpFile::fromCode($code));
$this->assertMatchesSnapshot($class->print());
}
diff --git a/packages/generation/tests/Php/Fixtures/SampleNamespace/SampleEnum.php b/packages/generation/tests/Php/Fixtures/SampleNamespace/SampleEnum.php
new file mode 100644
index 0000000000..e8ec1787ee
--- /dev/null
+++ b/packages/generation/tests/Php/Fixtures/SampleNamespace/SampleEnum.php
@@ -0,0 +1,10 @@
+addClassAttribute(TestAttribute::class, [
+ 'name' => 'example',
+ 'case' => SampleEnum::FIRST,
+ 'values' => [1, -2, 'three' => true, 'four' => null],
+ 'expression' => new Literal('PHP_VERSION_ID >= 80500'),
+ ]);
+
+ $this->assertMatchesSnapshot($class->print());
+ }
+
+ #[Test]
+ public function rejects_values_that_cannot_be_exported(): void
+ {
+ $class = new ClassGenerator('Example', namespace: 'App');
+
+ $this->expectException(ValueWasNotExportable::class);
+
+ $class->addClassAttribute(TestAttribute::class, [new stdClass()]);
+ }
+
+ #[Test]
+ public function keeps_multiline_strings_in_method_bodies_intact(): void
+ {
+ $class = new ClassGenerator('Example', namespace: 'App');
+ $class->addMethod('render', body: <<<'PHP'
+ $title = 'first line
+ second line';
+
+ return <<{$title}
+ HTML;
+ PHP, returnType: 'string');
+
+ $this->assertMatchesSnapshot($class->print());
+ }
+
+ #[Test]
+ public function adds_members_to_loaded_classes_without_reformatting_them(): void
+ {
+ $class = new ClassManipulator(CreateMigrationsTable::class);
+ $class->addTrait(ExampleTrait::class);
+ $class->addMethod('down', body: 'return null;', returnType: '?' . FakeQueryStatement::class);
+
+ $this->assertMatchesSnapshot($class->print());
+ }
+
+ #[Test]
+ public function loads_classes_from_reflection(): void
+ {
+ $class = new ClassManipulator(new ReflectionClass(ClassFromFile::class));
+
+ $this->assertSame(ClassFromFile::class, $class->getClassName());
+ }
+
+ #[Test]
+ public function throws_when_adding_an_attribute_to_a_missing_method(): void
+ {
+ $class = new ClassManipulator(ClassFromFile::class);
+
+ $this->expectException(MethodWasMissing::class);
+
+ $class->addMethodAttribute('missing', TestAttribute::class);
+ }
+
+ #[Test]
+ public function does_not_import_names_that_would_change_existing_references(): void
+ {
+ $file = PhpFile::fromCode(<<<'PHP'
+ print();
+
+ $this->assertStringContainsString('return [Thing::class, \Vendor\Thing::class, count([]), \Vendor\count()];', $code);
+ $this->assertStringNotContainsString('use ', $code);
+ $this->assertSame($code, $file->print());
+ }
+
+ #[Test]
+ public function reuses_grouped_imports(): void
+ {
+ $file = PhpFile::fromCode(<<<'PHP'
+ addUse('Vendor\Thing');
+
+ $this->assertSame(1, substr_count($file->print(), 'Thing'));
+ }
+
+ #[Test]
+ public function keeps_qualified_function_and_constant_names_working_when_the_namespace_changes(): void
+ {
+ $file = PhpFile::fromCode(<<<'PHP'
+ setNamespace('NewNamespace');
+
+ $this->assertStringContainsString('return \Old\Sub\f(\Old\Sub\VALUE);', $file->print());
+ }
+
+ #[Test]
+ public function keeps_imports_used_in_doc_comments(): void
+ {
+ $file = PhpFile::fromCode(<<<'PHP'
+ getClass()->removeAttribute('Vendor\Thing');
+
+ $this->assertStringContainsString('use Vendor\Thing;', $file->print());
+ }
+
+ #[Test]
+ public function enables_strict_types_when_declared_as_disabled(): void
+ {
+ $file = PhpFile::fromCode("assertFalse($file->hasStrictTypes());
+
+ $file->setStrictTypes();
+
+ $this->assertTrue($file->hasStrictTypes());
+ $this->assertStringContainsString('declare(strict_types=1);', $file->print());
+ }
+
+ #[Test]
+ public function exports_the_minimum_integer(): void
+ {
+ $class = new ClassGenerator('Example', namespace: 'App');
+ $class->addClassAttribute(TestAttribute::class, [PHP_INT_MIN]);
+
+ $code = $class->print();
+
+ $this->assertStringContainsString('#[TestAttribute(-9223372036854775807 - 1)]', $code);
+ }
+
+ #[Test]
+ public function keeps_strings_in_nested_interpolations_intact(): void
+ {
+ $class = new ClassGenerator('Example', namespace: 'App');
+ $class->addMethod('render', body: "return \"{\$this->format(\"first\n\$value\")}\";", returnType: 'string');
+
+ $this->assertStringContainsString("\"{\$this->format(\"first\n\$value\")}\"", $class->print());
+ }
+
+ #[Test]
+ public function keeps_tab_indented_heredocs_valid(): void
+ {
+ $class = new ClassGenerator('Example', namespace: 'App');
+ $class->addMethod('render', body: "return <<print();
+
+ $this->assertStringContainsString("<<assertNotEmpty(token_get_all($code, TOKEN_PARSE));
+ }
+
+ #[Test]
+ public function does_not_import_names_that_conflict_with_simplified_names(): void
+ {
+ $file = PhpFile::fromCode(<<<'PHP'
+ assertStringContainsString('return [Thing::class, \Vendor\Thing::class];', $file->print());
+ }
+
+ #[Test]
+ public function does_not_import_functions_that_conflict_with_declared_functions(): void
+ {
+ $file = PhpFile::fromCode(<<<'PHP'
+ print();
+
+ $this->assertStringContainsString('\Vendor\target();', $code);
+ $this->assertStringNotContainsString('use function', $code);
+ }
+
+ #[Test]
+ public function keeps_quoted_strings_inside_heredoc_interpolations_intact(): void
+ {
+ $class = new ClassGenerator('Example', namespace: 'App');
+ $class->addMethod('render', body: "return <<format('first\nsecond')}\nTXT;", returnType: 'string');
+
+ $this->assertStringContainsString("'first\nsecond'", $class->print());
+ }
+
+ #[Test]
+ public function keeps_namespace_imports_used_in_doc_comments(): void
+ {
+ $file = PhpFile::fromCode(<<<'PHP'
+ getClass()->removeAttribute('Vendor\Package\Attr');
+
+ $this->assertStringContainsString('use Vendor\Package as Models;', $file->print());
+ }
+
+ #[Test]
+ public function keeps_imports_used_in_added_doc_comments(): void
+ {
+ $file = PhpFile::fromCode(<<<'PHP'
+ getClass()->removeAttribute('Vendor\Thing')->setComment('@extends Thing');
+
+ $this->assertStringContainsString('use Vendor\Thing;', $file->print());
+ }
+
+ #[Test]
+ public function reindents_heredocs_containing_quotes_consistently(): void
+ {
+ $class = new ClassGenerator('Example', namespace: 'App');
+ $class->addMethod('render', body: "\$value = 'world';\nreturn <<assertStringContainsString(" return <<print());
+ }
+
+ #[Test]
+ public function keeps_strings_nested_in_heredoc_interpolations_intact(): void
+ {
+ $class = new ClassGenerator('Example', namespace: 'App');
+ $class->addMethod('render', body: "return <<format(\"outer{\$this->format(\"first\n\$value\")}\")}\nTXT;", returnType: 'string');
+
+ $this->assertStringContainsString("\"first\n\$value\"", $class->print());
+ }
+
+ #[Test]
+ public function replaces_bodies_of_loaded_methods_with_fully_qualified_signatures(): void
+ {
+ $file = PhpFile::fromCode(
+ "getClass()->getMethod('make')->setBody('return null;');
+
+ $code = $file->print();
+
+ $this->assertStringContainsString("public function make(): Result\n {\n return null;\n }", $code);
+ $this->assertNotEmpty(token_get_all($code, TOKEN_PARSE));
+ }
+
+ #[Test]
+ public function keeps_global_functions_and_constants_when_moving_code_into_a_namespace(): void
+ {
+ $file = PhpFile::fromCode("setNamespace('Changed');
+
+ $this->assertStringContainsString('return \\answer(\\VALUE, true);', $file->print());
+ }
+
+ #[Test]
+ public function keeps_imports_used_in_comments_of_added_method_bodies(): void
+ {
+ $file = PhpFile::fromCode(<<<'PHP'
+ getClass()->getMethod('mark')->removeAttribute('Other\\Marker')->setBody("/** @var Marker \$marker */\n\$marker = null;");
+
+ $this->assertStringContainsString('use Other\\Marker;', $file->print());
+ }
+
+ #[Test]
+ public function simplifies_names_in_classes_nested_in_added_method_bodies(): void
+ {
+ $class = new ClassGenerator('Example', namespace: 'App');
+ $class->addMethod('make', body: 'return new class { public function result(): \\Vendor\\Result { return new \\Vendor\\Result(); } };', returnType: 'object');
+
+ $code = $class->print();
+
+ $this->assertStringContainsString('use Vendor\\Result;', $code);
+ $this->assertStringContainsString('public function result(): Result { return new Result(); }', $code);
+ }
+
+ #[Test]
+ public function keeps_whitespace_only_lines_in_heredocs(): void
+ {
+ $class = new ClassGenerator('Example', namespace: 'App');
+ $class->addMethod('render', body: "return <<assertStringContainsString(" return <<print());
+ }
+}
diff --git a/packages/generation/tests/Php/StubFileTest.php b/packages/generation/tests/Php/StubFileTest.php
new file mode 100644
index 0000000000..730760239a
--- /dev/null
+++ b/packages/generation/tests/Php/StubFileTest.php
@@ -0,0 +1,72 @@
+path = tempnam(sys_get_temp_dir(), 'stub');
+ }
+
+ protected function tearDown(): void
+ {
+ unlink($this->path);
+ }
+
+ #[Test]
+ public function resolves_class_names_to_their_file(): void
+ {
+ $stub = StubFile::from(ClassFromFile::class);
+
+ $this->assertSame(StubFileType::CLASS_FILE, $stub->type);
+ $this->assertSame(realpath(__DIR__ . '/Fixtures/ClassFromFile.php'), $stub->filePath);
+ }
+
+ #[Test]
+ #[DataProvider('files')]
+ public function detects_the_type_of_files(string $contents, StubFileType $type): void
+ {
+ file_put_contents($this->path, $contents);
+
+ $this->assertSame($type, StubFile::from($this->path)->type);
+ }
+
+ public static function files(): iterable
+ {
+ yield 'class' => [" [" [" [" [" [" [" [" [" ["{{ \$title }}
\n", StubFileType::RAW_FILE];
+ yield 'invalid php' => ["expectException(InvalidArgumentException::class);
+
+ StubFile::from(__DIR__ . '/missing.php');
+ }
+}
diff --git a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__creates_class_from_scratch__1.txt b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__creates_class_from_scratch__1.txt
index 6dfc70e63c..7ca713d2fd 100644
--- a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__creates_class_from_scratch__1.txt
+++ b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__creates_class_from_scratch__1.txt
@@ -11,8 +11,8 @@ final readonly class CreateUsersTable implements \Tempest\Generation\Tests\Php\F
public function up(): FakeQueryStatement
{
return (new FakeCreateTableStatement(MigrationModel::table()))
- ->primary()
- ->text('name');
+ ->primary()
+ ->text('name');
}
public function getName(): string
diff --git a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__does_not_simplify_class_names__1.txt b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__does_not_simplify_class_names__1.txt
index c414054191..892761771b 100644
--- a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__does_not_simplify_class_names__1.txt
+++ b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__does_not_simplify_class_names__1.txt
@@ -9,7 +9,7 @@ class CreateUsersTable
public function up(): FakeQueryStatement
{
return (new \Tempest\Generation\Tests\Fixtures\Database\FakeCreateTableStatement(\Tempest\Generation\Tests\Fixtures\Database\MigrationModel::table()))
- ->primary()
- ->text('name');
+ ->primary()
+ ->text('name');
}
}
diff --git a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_class_modifiers__1.txt b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_class_modifiers__1.txt
index c598939ec8..07e6ab8b14 100644
--- a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_class_modifiers__1.txt
+++ b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_class_modifiers__1.txt
@@ -5,6 +5,4 @@ namespace App;
/**
* This creates a users table.
*/
-final readonly class CreateUsersTable
-{
-}
+final readonly class CreateUsersTable {}
diff --git a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_file_modifiers__1.txt b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_file_modifiers__1.txt
index 29b06953d0..a8537b21f1 100644
--- a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_file_modifiers__1.txt
+++ b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_file_modifiers__1.txt
@@ -8,6 +8,4 @@ declare(strict_types=1);
namespace App;
-class CreateUsersTable
-{
-}
+class CreateUsersTable {}
diff --git a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_long_namespace__1.txt b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_long_namespace__1.txt
index cbabc530cd..c05eb1d73e 100644
--- a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_long_namespace__1.txt
+++ b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__sets_long_namespace__1.txt
@@ -2,6 +2,4 @@
namespace App\Foo\Bar\Baz\Qux;
-class MyClass
-{
-}
+class MyClass {}
diff --git a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__simplifies_implements__1.txt b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__simplifies_implements__1.txt
index 5ec0cc74db..1486397a2a 100644
--- a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__simplifies_implements__1.txt
+++ b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__simplifies_implements__1.txt
@@ -4,6 +4,4 @@ namespace App;
use Tempest\Generation\Tests\Php\Fixtures\Database\FakeMigration;
-class CreateUsersTable implements FakeMigration
-{
-}
+class CreateUsersTable implements FakeMigration {}
diff --git a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__simplify_class_names_by_default__1.txt b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__simplify_class_names_by_default__1.txt
index 37c69ea17e..0986199687 100644
--- a/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__simplify_class_names_by_default__1.txt
+++ b/packages/generation/tests/Php/__snapshots__/ClassGeneratorTest__simplify_class_names_by_default__1.txt
@@ -11,7 +11,7 @@ class CreateUsersTable
public function up(): FakeQueryStatement
{
return (new FakeCreateTableStatement(MigrationModel::table()))
- ->primary()
- ->text('name');
+ ->primary()
+ ->text('name');
}
}
diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__does_not_simplify_class_names_by_default__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__does_not_simplify_class_names_by_default__1.txt
index c4a395bcf2..bddeafee40 100644
--- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__does_not_simplify_class_names_by_default__1.txt
+++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__does_not_simplify_class_names_by_default__1.txt
@@ -1,9 +1,15 @@
getTableDefinition()->name)
+ return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name)
->primary()
->text('name');
}
diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__does_not_simplify_implements_when_specified__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__does_not_simplify_implements_when_specified__1.txt
index 2e6cb5abcb..bddeafee40 100644
--- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__does_not_simplify_implements_when_specified__1.txt
+++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__does_not_simplify_implements_when_specified__1.txt
@@ -1,15 +1,18 @@
getTableDefinition()->name)
+ return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name)
->primary()
->text('name');
}
diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__from_file__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__from_file__1.txt
index ca30f51cd7..2ca7a96e47 100644
--- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__from_file__1.txt
+++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__from_file__1.txt
@@ -1,7 +1,7 @@
getTableDefinition()->name)
+ return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name)
->primary()
->text('name');
}
diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__set_aliases__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__set_aliases__1.txt
index 32fa140c70..bddeafee40 100644
--- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__set_aliases__1.txt
+++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__set_aliases__1.txt
@@ -1,11 +1,12 @@
getTableDefinition()->name)
+ return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name)
->primary()
->text('name');
}
diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__sets_class_readonly__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__sets_class_readonly__1.txt
index 824fe09084..bddeafee40 100644
--- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__sets_class_readonly__1.txt
+++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__sets_class_readonly__1.txt
@@ -1,11 +1,13 @@
getTableDefinition()->name)
+ return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name)
->primary()
->text('name');
}
diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__sets_strict_types__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__sets_strict_types__1.txt
index 5d86c73f13..bddeafee40 100644
--- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__sets_strict_types__1.txt
+++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__sets_strict_types__1.txt
@@ -7,7 +7,7 @@ namespace Tempest\Generation\Tests\Php\Fixtures;
use Tempest\Generation\Tests\Php\Fixtures\Database\FakeCreateTableStatement;
use Tempest\Generation\Tests\Php\Fixtures\Database\FakeMigration;
use Tempest\Generation\Tests\Php\Fixtures\Database\FakeQueryStatement;
-use Tempest\Generation\Tests\Php\Fixtures\Database\MigrationModel;
+use Tempest\Generation\Tests\Php\Fixtures\Database\MigrationModel as Model;
use function Tempest\Database\inspect;
@@ -21,7 +21,7 @@ final readonly class CreateMigrationsTable implements FakeMigration
public function up(): FakeQueryStatement
{
- return new FakeCreateTableStatement(inspect(MigrationModel::class)->getTableDefinition()->name)
+ return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name)
->primary()
->text('name');
}
diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__simplifies_class_names_by_default__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__simplifies_class_names_by_default__1.txt
index 824fe09084..bddeafee40 100644
--- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__simplifies_class_names_by_default__1.txt
+++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__simplifies_class_names_by_default__1.txt
@@ -1,11 +1,13 @@
getTableDefinition()->name)
+ return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name)
->primary()
->text('name');
}
diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__make_replacements_before_converting_fqcn__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__simplifies_fully_qualified_names_in_loaded_code__1.txt
similarity index 64%
rename from packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__make_replacements_before_converting_fqcn__1.txt
rename to packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__simplifies_fully_qualified_names_in_loaded_code__1.txt
index 9024b6eaaf..5fbc971f21 100644
--- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__make_replacements_before_converting_fqcn__1.txt
+++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__simplifies_fully_qualified_names_in_loaded_code__1.txt
@@ -1,8 +1,10 @@
getTableDefinition()->name)
+ return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name)
->primary()
->text('name');
}
diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__unsets_class_readonly__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__unsets_class_readonly__1.txt
index 55cf00ebeb..df8bab639a 100644
--- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__unsets_class_readonly__1.txt
+++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__unsets_class_readonly__1.txt
@@ -1,11 +1,13 @@
getTableDefinition()->name)
+ return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name)
->primary()
->text('name');
}
diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__unsets_strict_types__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__unsets_strict_types__1.txt
index 824fe09084..170df7d6e1 100644
--- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__unsets_strict_types__1.txt
+++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__unsets_strict_types__1.txt
@@ -5,7 +5,7 @@ namespace Tempest\Generation\Tests\Php\Fixtures;
use Tempest\Generation\Tests\Php\Fixtures\Database\FakeCreateTableStatement;
use Tempest\Generation\Tests\Php\Fixtures\Database\FakeMigration;
use Tempest\Generation\Tests\Php\Fixtures\Database\FakeQueryStatement;
-use Tempest\Generation\Tests\Php\Fixtures\Database\MigrationModel;
+use Tempest\Generation\Tests\Php\Fixtures\Database\MigrationModel as Model;
use function Tempest\Database\inspect;
@@ -19,7 +19,7 @@ final readonly class CreateMigrationsTable implements FakeMigration
public function up(): FakeQueryStatement
{
- return new FakeCreateTableStatement(inspect(MigrationModel::class)->getTableDefinition()->name)
+ return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name)
->primary()
->text('name');
}
diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__updates_namespace__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__updates_namespace__1.txt
index c41794d92e..f996c89629 100644
--- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__updates_namespace__1.txt
+++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__updates_namespace__1.txt
@@ -1,11 +1,13 @@
getTableDefinition()->name)
+ return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name)
->primary()
->text('name');
}
diff --git a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__updates_namespace_multiple_times__1.txt b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__updates_namespace_multiple_times__1.txt
index 25fa319b5c..3845f7f40a 100644
--- a/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__updates_namespace_multiple_times__1.txt
+++ b/packages/generation/tests/Php/__snapshots__/ClassManipulatorTest__updates_namespace_multiple_times__1.txt
@@ -1,11 +1,13 @@
getTableDefinition()->name)
+ return new FakeCreateTableStatement(inspect(Model::class)->getTableDefinition()->name)
->primary()
->text('name');
}
diff --git a/packages/generation/tests/Php/__snapshots__/PhpFileTest__adds_members_to_loaded_classes_without_reformatting_them__1.txt b/packages/generation/tests/Php/__snapshots__/PhpFileTest__adds_members_to_loaded_classes_without_reformatting_them__1.txt
new file mode 100644
index 0000000000..fc55738df1
--- /dev/null
+++ b/packages/generation/tests/Php/__snapshots__/PhpFileTest__adds_members_to_loaded_classes_without_reformatting_them__1.txt
@@ -0,0 +1,36 @@
+getTableDefinition()->name)
+ ->primary()
+ ->text('name');
+ }
+
+ public function down(): ?FakeQueryStatement
+ {
+ return null;
+ }
+}
diff --git a/packages/generation/tests/Php/__snapshots__/PhpFileTest__exports_attribute_arguments__1.txt b/packages/generation/tests/Php/__snapshots__/PhpFileTest__exports_attribute_arguments__1.txt
new file mode 100644
index 0000000000..a07c3bd6f3
--- /dev/null
+++ b/packages/generation/tests/Php/__snapshots__/PhpFileTest__exports_attribute_arguments__1.txt
@@ -0,0 +1,9 @@
+ true, 'four' => null], expression: PHP_VERSION_ID >= 80500)]
+class Example {}
diff --git a/packages/generation/tests/Php/__snapshots__/PhpFileTest__keeps_multiline_strings_in_method_bodies_intact__1.txt b/packages/generation/tests/Php/__snapshots__/PhpFileTest__keeps_multiline_strings_in_method_bodies_intact__1.txt
new file mode 100644
index 0000000000..830bffe8bd
--- /dev/null
+++ b/packages/generation/tests/Php/__snapshots__/PhpFileTest__keeps_multiline_strings_in_method_bodies_intact__1.txt
@@ -0,0 +1,16 @@
+{$title}
+ HTML;
+ }
+}
diff --git a/packages/http/src/Session/CleanupStrategy.php b/packages/http/src/Session/CleanupStrategy.php
index e08b709e33..5b89eba7e6 100644
--- a/packages/http/src/Session/CleanupStrategy.php
+++ b/packages/http/src/Session/CleanupStrategy.php
@@ -10,7 +10,7 @@ enum CleanupStrategy
case EVERY_REQUEST;
/**
- * Expired sessions will be cleaned up after a random request. This is the default strategy, and provides a good balance between performance and cleanup frequency.
+ * Expired sessions will be cleaned up after a random request, at most once per minute. This is the default strategy, and provides a good balance between performance and cleanup frequency.
*/
case RANDOM_REQUESTS;
diff --git a/packages/http/src/Session/ManageSessionMiddleware.php b/packages/http/src/Session/ManageSessionMiddleware.php
index 5b7e31aea3..6b9aafb98b 100644
--- a/packages/http/src/Session/ManageSessionMiddleware.php
+++ b/packages/http/src/Session/ManageSessionMiddleware.php
@@ -2,24 +2,34 @@
namespace Tempest\Http\Session;
+use Tempest\Clock\Clock;
use Tempest\Core\DeferredTasks;
use Tempest\Http\Request;
use Tempest\Http\Response;
use Tempest\Router\HttpMiddleware;
use Tempest\Router\HttpMiddlewareCallable;
+use Tempest\Router\SecFetchMode;
use Tempest\Support\Priority;
+use function Tempest\internal_storage_path;
+
/**
* This middleware is responsible for creating the session and saving it on response.
*/
#[Priority(Priority::FRAMEWORK - 20)]
final readonly class ManageSessionMiddleware implements HttpMiddleware
{
+ /**
+ * Minimum amount of seconds between two cleanups when using the `RANDOM_REQUESTS` strategy.
+ */
+ private const int RANDOM_REQUESTS_CLEANUP_INTERVAL = 60;
+
public function __construct(
private SessionManager $sessionManager,
private Session $session,
private SessionConfig $config,
private DeferredTasks $deferredTasks,
+ private Clock $clock,
) {}
public function __invoke(Request $request, HttpMiddlewareCallable $next): Response
@@ -32,7 +42,9 @@ public function __invoke(Request $request, HttpMiddlewareCallable $next): Respon
try {
return $next($request);
} finally {
- $this->sessionManager->save($this->session);
+ if ($this->shouldPersist($request)) {
+ $this->sessionManager->save($this->session);
+ }
match ($this->config->cleanupStrategy) {
CleanupStrategy::EVERY_REQUEST => $this->scheduleSessionCleanup(),
@@ -42,6 +54,37 @@ public function __invoke(Request $request, HttpMiddlewareCallable $next): Respon
}
}
+ /**
+ * Clients that don't send a session cookie, like API consumers, crawlers or load testing tools, get a new session on every request.
+ * Those sessions are only persisted once they contain something worth keeping, so that they don't pile up in storage.
+ */
+ private function shouldPersist(Request $request): bool
+ {
+ if (! $this->session->isNew) {
+ return true;
+ }
+
+ $data = $this->session->all();
+
+ // The previous URL is tracked on every request, so it alone doesn't warrant persisting a new session.
+ if (! $this->needsPreviousUrl($request)) {
+ unset($data[PreviousUrl::PREVIOUS_URL_SESSION_KEY]);
+ }
+
+ return $data !== [];
+ }
+
+ /**
+ * Browsers navigating between pages need the previous URL to be kept, because `Back` can't rely on the referer:
+ * the request that redirects back may not send one, for example because of a `no-referrer` policy.
+ * Other clients, like crawlers and API consumers, don't send `Sec-Fetch-*` headers, and can't submit forms
+ * that redirect back either, since `PreventCrossSiteRequestsMiddleware` requires them.
+ */
+ private function needsPreviousUrl(Request $request): bool
+ {
+ return SecFetchMode::tryFrom($request->headers->get('sec-fetch-mode', default: '')) === SecFetchMode::NAVIGATE;
+ }
+
private function scheduleSessionCleanup(): void
{
$this->deferredTasks->add(
@@ -52,8 +95,51 @@ private function scheduleSessionCleanup(): void
private function maybeScheduleSessionCleanup(): void
{
- if (random_int(min: 1, max: 50) === 1) {
- $this->scheduleSessionCleanup();
+ if (random_int(min: 1, max: 50) !== 1) {
+ return;
+ }
+
+ if (! $this->claimSessionCleanup()) {
+ return;
+ }
+
+ $this->scheduleSessionCleanup();
+ }
+
+ /**
+ * Cleaning up has to go over all sessions, so it is throttled to keep its cost from growing along with the amount of sessions and traffic.
+ * The last cleanup time is read, checked and updated while holding a lock, so that concurrent requests can't both claim the same cleanup.
+ */
+ private function claimSessionCleanup(): bool
+ {
+ $handle = @fopen(internal_storage_path('session-cleanup'), mode: 'c+');
+
+ if ($handle === false) {
+ return false;
+ }
+
+ try {
+ // Another request holding the lock is already claiming the cleanup, so there's no need to wait for it.
+ if (! flock($handle, LOCK_EX | LOCK_NB)) {
+ return false;
+ }
+
+ $now = $this->clock->now()->getTimestamp()->getSeconds();
+ $lastCleanupAt = (int) stream_get_contents($handle);
+
+ if (($now - $lastCleanupAt) < self::RANDOM_REQUESTS_CLEANUP_INTERVAL) {
+ return false;
+ }
+
+ ftruncate($handle, size: 0);
+ rewind($handle);
+ fwrite($handle, (string) $now);
+ fflush($handle);
+
+ return true;
+ } finally {
+ flock($handle, LOCK_UN);
+ fclose($handle);
}
}
}
diff --git a/packages/http/src/Session/Managers/DatabaseSessionManager.php b/packages/http/src/Session/Managers/DatabaseSessionManager.php
index ad6f1cf964..99dbfe95ef 100644
--- a/packages/http/src/Session/Managers/DatabaseSessionManager.php
+++ b/packages/http/src/Session/Managers/DatabaseSessionManager.php
@@ -11,6 +11,7 @@
use Tempest\Http\Session\SessionCreated;
use Tempest\Http\Session\SessionDeleted;
use Tempest\Http\Session\SessionId;
+use Tempest\Http\Session\SessionIdResolver;
use Tempest\Http\Session\SessionManager;
use function Tempest\Database\query;
@@ -21,6 +22,7 @@
public function __construct(
private Clock $clock,
private SessionConfig $config,
+ private SessionIdResolver $sessionIdResolver,
) {}
public function getOrCreate(SessionId $id): Session
@@ -33,6 +35,7 @@ public function getOrCreate(SessionId $id): Session
id: $id,
createdAt: $now,
lastActiveAt: $now,
+ isNew: true,
);
event(new SessionCreated($session));
@@ -60,6 +63,20 @@ public function save(Session $session): void
)
->execute();
+ $session->markAsPersisted();
+
+ return;
+ }
+
+ // Unchanged sessions only need their activity to be tracked.
+ if (! $session->isDirty()) {
+ query(DatabaseSession::class)
+ ->update(last_active_at: $session->lastActiveAt)
+ ->where('id', (string) $session->id)
+ ->execute();
+
+ $session->markAsPersisted();
+
return;
}
@@ -70,6 +87,8 @@ public function save(Session $session): void
)
->where('id', (string) $session->id)
->execute();
+
+ $session->markAsPersisted();
}
public function delete(Session $session): void
@@ -82,6 +101,15 @@ public function delete(Session $session): void
event(new SessionDeleted($session->id));
}
+ public function regenerate(Session $session): void
+ {
+ $this->delete($session);
+
+ $session->replaceId($this->sessionIdResolver->issueNewId());
+
+ $this->save($session);
+ }
+
public function isValid(Session $session): bool
{
return $this->clock->now()->before(
@@ -95,18 +123,27 @@ public function deleteExpiredSessions(): void
->now()
->minus($this->config->expiration);
- $expiredSessions = query(DatabaseSession::class)
- ->select()
- ->where('last_active_at < ?', $expired->format(FormatPattern::SQL_DATE_TIME))
- ->all();
+ $expiredBefore = $expired->format(FormatPattern::SQL_DATE_TIME);
- foreach ($expiredSessions as $expiredSession) {
+ $expiredIds = array_map(
+ static fn (DatabaseSession $session) => (string) $session->id,
query(DatabaseSession::class)
- ->delete()
- ->where('id', $expiredSession->id)
- ->execute();
+ ->select('id')
+ ->where('last_active_at < ?', $expiredBefore)
+ ->all(),
+ );
+
+ if ($expiredIds === []) {
+ return;
+ }
+
+ query(DatabaseSession::class)
+ ->delete()
+ ->where('last_active_at < ?', $expiredBefore)
+ ->execute();
- event(new SessionDeleted(new SessionId((string) $expiredSession->id)));
+ foreach ($expiredIds as $expiredId) {
+ event(new SessionDeleted(new SessionId($expiredId)));
}
}
diff --git a/packages/http/src/Session/Managers/FileSessionManager.php b/packages/http/src/Session/Managers/FileSessionManager.php
index aebe39a599..cbe570027a 100644
--- a/packages/http/src/Session/Managers/FileSessionManager.php
+++ b/packages/http/src/Session/Managers/FileSessionManager.php
@@ -5,11 +5,13 @@
namespace Tempest\Http\Session\Managers;
use Tempest\Clock\Clock;
+use Tempest\DateTime\DateTime;
use Tempest\Http\Session\Config\FileSessionConfig;
use Tempest\Http\Session\Session;
use Tempest\Http\Session\SessionCreated;
use Tempest\Http\Session\SessionDeleted;
use Tempest\Http\Session\SessionId;
+use Tempest\Http\Session\SessionIdResolver;
use Tempest\Http\Session\SessionManager;
use Tempest\Support\Filesystem;
use Throwable;
@@ -22,6 +24,7 @@
public function __construct(
private Clock $clock,
private FileSessionConfig $sessionConfig, // TODO: rename to $config, see RedisSessionManager and DatabaseSessionManager
+ private SessionIdResolver $sessionIdResolver,
) {}
public function getOrCreate(SessionId $id): Session
@@ -34,6 +37,7 @@ public function getOrCreate(SessionId $id): Session
id: $id,
createdAt: $now,
lastActiveAt: $now,
+ isNew: true,
);
event(new SessionCreated($session));
@@ -44,13 +48,23 @@ public function getOrCreate(SessionId $id): Session
public function save(Session $session): void
{
- $session->lastActiveAt = $this->clock->now();
+ $now = $this->clock->now();
+ $path = $this->getPath($session->id);
- Filesystem\write_file(
- filename: $this->getPath($session->id),
- content: serialize($session),
- flags: LOCK_EX,
- );
+ $session->lastActiveAt = $now;
+
+ // Unchanged sessions aren't rewritten, their activity is tracked through the file's modification time instead.
+ if ($session->isDirty() || ! Filesystem\is_file($path)) {
+ Filesystem\write_file(
+ filename: $path,
+ content: serialize($session),
+ flags: LOCK_EX,
+ );
+ }
+
+ touch($path, $now->getTimestamp()->getSeconds());
+
+ $session->markAsPersisted();
}
public function delete(Session $session): void
@@ -62,6 +76,15 @@ public function delete(Session $session): void
event(new SessionDeleted($session->id));
}
+ public function regenerate(Session $session): void
+ {
+ $this->delete($session);
+
+ $session->replaceId($this->sessionIdResolver->issueNewId());
+
+ $this->save($session);
+ }
+
public function isValid(Session $session): bool
{
return $this->clock->now()->before(
@@ -77,19 +100,23 @@ public function deleteExpiredSessions(): void
return;
}
- foreach ($sessionFiles as $sessionFile) {
- $id = new SessionId(pathinfo($sessionFile, flags: PATHINFO_FILENAME));
- $session = $this->load($id);
+ $expiredBefore = $this->clock->now()->minus($this->sessionConfig->expiration)->getTimestamp()->getSeconds();
- if (! $session instanceof Session) {
+ foreach ($sessionFiles as $sessionFile) {
+ $lastActiveAt = @filemtime($sessionFile);
+ if ($lastActiveAt === false) {
continue;
}
- if ($this->isValid($session)) {
+ if ($lastActiveAt > $expiredBefore) {
continue;
}
- $this->delete($session);
+ $id = new SessionId(pathinfo($sessionFile, flags: PATHINFO_FILENAME));
+
+ Filesystem\delete($sessionFile);
+
+ event(new SessionDeleted($id));
}
}
@@ -102,12 +129,24 @@ private function load(SessionId $id): ?Session
return null;
}
- return unserialize(
+ $session = unserialize(
data: Filesystem\read_locked_file($path),
options: [
'allowed_classes' => true,
],
);
+
+ if (! $session instanceof Session) {
+ return null;
+ }
+
+ $lastModifiedAt = filemtime($path);
+
+ if ($lastModifiedAt !== false && $lastModifiedAt > $session->lastActiveAt->getTimestamp()->getSeconds()) {
+ $session->lastActiveAt = DateTime::fromTimestamp($lastModifiedAt);
+ }
+
+ return $session;
} catch (Throwable) {
return null;
}
diff --git a/packages/http/src/Session/Managers/RedisSessionManager.php b/packages/http/src/Session/Managers/RedisSessionManager.php
index 6e19e9f4ab..404dddb6fc 100644
--- a/packages/http/src/Session/Managers/RedisSessionManager.php
+++ b/packages/http/src/Session/Managers/RedisSessionManager.php
@@ -10,6 +10,7 @@
use Tempest\Http\Session\SessionCreated;
use Tempest\Http\Session\SessionDeleted;
use Tempest\Http\Session\SessionId;
+use Tempest\Http\Session\SessionIdResolver;
use Tempest\Http\Session\SessionManager;
use Tempest\KeyValue\Redis\Redis;
use Tempest\Support\Str;
@@ -23,6 +24,7 @@ public function __construct(
private Clock $clock,
private Redis $redis,
private RedisSessionConfig $config,
+ private SessionIdResolver $sessionIdResolver,
) {}
public function getOrCreate(SessionId $id): Session
@@ -35,6 +37,7 @@ public function getOrCreate(SessionId $id): Session
id: $id,
createdAt: $now,
lastActiveAt: $now,
+ isNew: true,
);
event(new SessionCreated($session));
@@ -52,6 +55,8 @@ public function save(Session $session): void
value: serialize($session),
expiration: $this->config->expiration,
);
+
+ $session->markAsPersisted();
}
public function delete(Session $session): void
@@ -61,6 +66,15 @@ public function delete(Session $session): void
event(new SessionDeleted($session->id));
}
+ public function regenerate(Session $session): void
+ {
+ $this->delete($session);
+
+ $session->replaceId($this->sessionIdResolver->issueNewId());
+
+ $this->save($session);
+ }
+
public function isValid(Session $session): bool
{
return $this->clock->now()->before(
diff --git a/packages/http/src/Session/PreviousUrl.php b/packages/http/src/Session/PreviousUrl.php
index 9fe22a66eb..a515ecff97 100644
--- a/packages/http/src/Session/PreviousUrl.php
+++ b/packages/http/src/Session/PreviousUrl.php
@@ -12,7 +12,7 @@
*/
final readonly class PreviousUrl
{
- private const string PREVIOUS_URL_SESSION_KEY = '#previous_url';
+ public const string PREVIOUS_URL_SESSION_KEY = '#previous_url';
private const string INTENDED_URL_SESSION_KEY = '#intended_url';
diff --git a/packages/http/src/Session/Resolvers/CookieSessionIdResolver.php b/packages/http/src/Session/Resolvers/CookieSessionIdResolver.php
index 4c5077ac76..25b8831ff8 100644
--- a/packages/http/src/Session/Resolvers/CookieSessionIdResolver.php
+++ b/packages/http/src/Session/Resolvers/CookieSessionIdResolver.php
@@ -30,27 +30,37 @@ public function __construct(
public function resolve(): SessionId
{
- $sessionKey = str($this->appConfig->name ?? 'tempest')
- ->snake()
- ->append('_session_id')
- ->toString();
-
- $id = $this->request->getCookie($sessionKey)?->value;
+ $id = $this->request->getCookie($this->getSessionKey())?->value;
if (! $id) {
- $id = (string) Uuid::v4();
-
- $this->cookies->add(new Cookie(
- key: $sessionKey,
- value: $id,
- expiresAt: $this->clock->now()->plus($this->sessionConfig->expiration),
- path: '/',
- secure: Str\starts_with($this->appConfig->baseUri, needles: 'https'),
- httpOnly: true,
- sameSite: SameSite::LAX,
- ));
+ return $this->issueNewId();
}
return new SessionId($id);
}
+
+ public function issueNewId(): SessionId
+ {
+ $id = (string) Uuid::v4();
+
+ $this->cookies->add(new Cookie(
+ key: $this->getSessionKey(),
+ value: $id,
+ expiresAt: $this->clock->now()->plus($this->sessionConfig->expiration),
+ path: '/',
+ secure: Str\starts_with($this->appConfig->baseUri, needles: 'https'),
+ httpOnly: true,
+ sameSite: SameSite::LAX,
+ ));
+
+ return new SessionId($id);
+ }
+
+ private function getSessionKey(): string
+ {
+ return str($this->appConfig->name ?? 'tempest')
+ ->snake()
+ ->append('_session_id')
+ ->toString();
+ }
}
diff --git a/packages/http/src/Session/Resolvers/HeaderSessionIdResolver.php b/packages/http/src/Session/Resolvers/HeaderSessionIdResolver.php
index 93f540cde6..bef29a19cf 100644
--- a/packages/http/src/Session/Resolvers/HeaderSessionIdResolver.php
+++ b/packages/http/src/Session/Resolvers/HeaderSessionIdResolver.php
@@ -35,4 +35,9 @@ public function resolve(): SessionId
id: $this->request->headers[$sessionKey] ?? Uuid::v4()->toString(),
);
}
+
+ public function issueNewId(): SessionId
+ {
+ return new SessionId(id: Uuid::v4()->toString());
+ }
}
diff --git a/packages/http/src/Session/Session.php b/packages/http/src/Session/Session.php
index 78846c4a92..d9db181aac 100644
--- a/packages/http/src/Session/Session.php
+++ b/packages/http/src/Session/Session.php
@@ -22,13 +22,21 @@ final class Session
*/
private array $expiredKeys = [];
+ /**
+ * Fingerprint of the session's state when it was loaded or last persisted.
+ */
+ private string $persistedFingerprint;
+
public function __construct(
private(set) SessionId $id,
private(set) DateTimeInterface $createdAt,
public DateTimeInterface $lastActiveAt,
/** @var array */
private(set) array $data = [],
- ) {}
+ private(set) bool $isNew = false,
+ ) {
+ $this->persistedFingerprint = $this->fingerprint();
+ }
/**
* Sets a value in the session.
@@ -132,6 +140,15 @@ public function cleanup(): void
}
}
+ /**
+ * @internal Prefer {@see SessionManager::regenerate()}, which also destroys the session that
+ * is being replaced and sends the new identifier to the client.
+ */
+ public function replaceId(SessionId $id): void
+ {
+ $this->id = $id;
+ }
+
/**
* Clears all values from the session.
*/
@@ -140,6 +157,33 @@ public function clear(): void
$this->data = [];
}
+ /**
+ * Determines whether the session's data changed since it was loaded or last persisted.
+ */
+ public function isDirty(): bool
+ {
+ return $this->fingerprint() !== $this->persistedFingerprint;
+ }
+
+ /**
+ * Marks the session as persisted, so that it is no longer considered new or dirty.
+ *
+ * @internal
+ */
+ public function markAsPersisted(): void
+ {
+ $this->isNew = false;
+ $this->persistedFingerprint = $this->fingerprint();
+ }
+
+ /**
+ * Values may be objects that are modified in place, so the serialized state is compared instead of tracking changes.
+ */
+ private function fingerprint(): string
+ {
+ return hash('xxh128', serialize([$this->data, $this->expiredKeys]));
+ }
+
public function __serialize(): array
{
return [
@@ -158,5 +202,7 @@ public function __unserialize(array $data): void
$this->lastActiveAt = DateTime::fromTimestamp($data['last_active_at']);
$this->data = $data['data'];
$this->expiredKeys = $data['expired_keys'];
+ $this->isNew = false;
+ $this->persistedFingerprint = $this->fingerprint();
}
}
diff --git a/packages/http/src/Session/SessionIdResolver.php b/packages/http/src/Session/SessionIdResolver.php
index 6b49d69ddb..782112e29d 100644
--- a/packages/http/src/Session/SessionIdResolver.php
+++ b/packages/http/src/Session/SessionIdResolver.php
@@ -6,5 +6,15 @@
interface SessionIdResolver
{
+ /**
+ * Resolves the identifier sent by the client, creating a new one if there is none.
+ */
public function resolve(): SessionId;
+
+ /**
+ * Creates a new identifier and sends it to the client, replacing the one it was using.
+ *
+ * @see SessionManager::regenerate()
+ */
+ public function issueNewId(): SessionId;
}
diff --git a/packages/http/src/Session/SessionManager.php b/packages/http/src/Session/SessionManager.php
index cc904b1d9b..ad0b901bb9 100644
--- a/packages/http/src/Session/SessionManager.php
+++ b/packages/http/src/Session/SessionManager.php
@@ -21,6 +21,18 @@ public function save(Session $session): void;
*/
public function delete(Session $session): void;
+ /**
+ * Assigns a new identifier to the session, destroying the session it replaces
+ * and sending the new identifier to the client. Session data is carried over.
+ *
+ * This protects against session fixation, and should be done whenever the session
+ * changes privilege level - such as authentication or a password change.
+ *
+ * @see https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
+ * @see https://owasp.org/www-community/attacks/Session_fixation
+ */
+ public function regenerate(Session $session): void;
+
/**
* Determines whether the session is still valid.
*/
diff --git a/packages/mapper/src/Mappers/ArrayToObjectMapper.php b/packages/mapper/src/Mappers/ArrayToObjectMapper.php
index 64e44012fc..156138588f 100644
--- a/packages/mapper/src/Mappers/ArrayToObjectMapper.php
+++ b/packages/mapper/src/Mappers/ArrayToObjectMapper.php
@@ -11,6 +11,7 @@
use Tempest\Mapper\Mapper;
use Tempest\Mapper\Strict;
use Tempest\Reflection\ClassReflector;
+use Tempest\Reflection\ParameterReflector;
use Tempest\Reflection\PropertyReflector;
use Tempest\Reflection\TypeReflector;
use Tempest\Support\Arr;
@@ -64,6 +65,10 @@ public function map(mixed $from, mixed $to): object
if ($isMissing && ($getHook = $property->getGetHook())) {
$from[$propertyName] = $getHook->invokeArgs($targetObject);
} elseif ($isMissing) {
+ if ($this->applyPromotedDefault($property, $targetObject)) {
+ continue;
+ }
+
$this->handleMissingProperty(
property: $property,
propertyName: $propertyName,
@@ -133,6 +138,28 @@ private function resolveObject(mixed $objectOrClass): object
return new ClassReflector($objectOrClass)->newInstanceWithoutConstructor();
}
+ /**
+ * Readonly properties cannot declare a class-level default, so a promoted
+ * constructor parameter is the only place a default can live for them.
+ * Returns false when no default could be applied.
+ */
+ private function applyPromotedDefault(PropertyReflector $property, object $targetObject): bool
+ {
+ if (! $property->isReadonly() || ! $property->hasDefaultValue()) {
+ return false;
+ }
+
+ $parameter = $property->getClass()->getConstructor()?->getParameter($property->getName());
+
+ if (! $parameter instanceof ParameterReflector || ! $parameter->isDefaultValueAvailable()) {
+ return false;
+ }
+
+ $property->setValue($targetObject, $parameter->getDefaultValue());
+
+ return true;
+ }
+
private function setParentRelations(object $parent, ClassReflector $parentClass): void
{
static $plans = [];
@@ -163,7 +190,11 @@ function (PropertyReflector $property): ?array {
$child = $property->getValue($parent);
- if ($child === null || $child === []) {
+ if ($child === null) {
+ continue;
+ }
+
+ if ($child === []) {
continue;
}
diff --git a/packages/router/src/Exceptions/HttpExceptionHandler.php b/packages/router/src/Exceptions/HttpExceptionHandler.php
index 3d366178b2..247bd08931 100644
--- a/packages/router/src/Exceptions/HttpExceptionHandler.php
+++ b/packages/router/src/Exceptions/HttpExceptionHandler.php
@@ -7,6 +7,7 @@
use Tempest\Core\Exceptions\ExceptionProcessor;
use Tempest\Core\Kernel;
use Tempest\Http\GenericResponse;
+use Tempest\Http\Header;
use Tempest\Http\HttpRequestFailed;
use Tempest\Http\Request;
use Tempest\Http\Response;
@@ -67,7 +68,7 @@ private function applyHeaders(Response $response, Throwable $throwable): Respons
// Headers keep the casing they were added with, so we look up the existing one to avoid a duplicate
$existing = $response->getHeader($name);
- $response->removeHeader($existing === null ? $name : $existing->name);
+ $response->removeHeader($existing instanceof Header ? $existing->name : $name);
foreach (is_array($values) ? $values : [$values] as $value) {
$response->addHeader($name, $value);
diff --git a/packages/upgrade/config/sets/level/up-to-tempest-321.php b/packages/upgrade/config/sets/level/up-to-tempest-321.php
new file mode 100644
index 0000000000..158f0d316e
--- /dev/null
+++ b/packages/upgrade/config/sets/level/up-to-tempest-321.php
@@ -0,0 +1,18 @@
+sets([
+ TempestSetList::TEMPEST_20,
+ TempestSetList::TEMPEST_28,
+ TempestSetList::TEMPEST_30,
+ TempestSetList::TEMPEST_34,
+ TempestSetList::TEMPEST_310,
+ TempestSetList::TEMPEST_314,
+ TempestSetList::TEMPEST_321,
+ ]);
+};
diff --git a/packages/upgrade/config/sets/tempest321.php b/packages/upgrade/config/sets/tempest321.php
new file mode 100644
index 0000000000..e8d3436fd7
--- /dev/null
+++ b/packages/upgrade/config/sets/tempest321.php
@@ -0,0 +1,15 @@
+rule(UpdateSessionManagerImplementationsRector::class);
+ $config->rule(UpdateSessionIdResolverImplementationsRector::class);
+};
diff --git a/packages/upgrade/src/Set/TempestLevelSetList.php b/packages/upgrade/src/Set/TempestLevelSetList.php
index 458d7f992e..a9836c64f8 100644
--- a/packages/upgrade/src/Set/TempestLevelSetList.php
+++ b/packages/upgrade/src/Set/TempestLevelSetList.php
@@ -17,4 +17,6 @@ final class TempestLevelSetList
public const string UP_TO_TEMPEST_310 = __DIR__ . '/../../config/sets/level/up-to-tempest-310.php';
public const string UP_TO_TEMPEST_314 = __DIR__ . '/../../config/sets/level/up-to-tempest-314.php';
+
+ public const string UP_TO_TEMPEST_321 = __DIR__ . '/../../config/sets/level/up-to-tempest-321.php';
}
diff --git a/packages/upgrade/src/Set/TempestSetList.php b/packages/upgrade/src/Set/TempestSetList.php
index e786902548..cf475b8265 100644
--- a/packages/upgrade/src/Set/TempestSetList.php
+++ b/packages/upgrade/src/Set/TempestSetList.php
@@ -17,4 +17,6 @@ final class TempestSetList
public const string TEMPEST_310 = __DIR__ . '/../../config/sets/tempest310.php';
public const string TEMPEST_314 = __DIR__ . '/../../config/sets/tempest314.php';
+
+ public const string TEMPEST_321 = __DIR__ . '/../../config/sets/tempest321.php';
}
diff --git a/packages/upgrade/src/Tempest321/UpdateSessionIdResolverImplementationsRector.php b/packages/upgrade/src/Tempest321/UpdateSessionIdResolverImplementationsRector.php
new file mode 100644
index 0000000000..70f0434b25
--- /dev/null
+++ b/packages/upgrade/src/Tempest321/UpdateSessionIdResolverImplementationsRector.php
@@ -0,0 +1,60 @@
+implements, fn (Name $name) => $this->isName($name, SessionIdResolver::class))) {
+ return null;
+ }
+
+ if ($node->getMethod('issueNewId') instanceof ClassMethod) {
+ return null;
+ }
+
+ $node->stmts[] = $this->createIssueNewIdMethod();
+
+ return $node;
+ }
+
+ private function createIssueNewIdMethod(): ClassMethod
+ {
+ $method = $this->nodeFactory->createPublicMethod('issueNewId');
+
+ $method->returnType = new FullyQualified(SessionId::class);
+ $method->stmts = [
+ new Expression(new Throw_(new New_(
+ new FullyQualified(BadMethodCallException::class),
+ $this->nodeFactory->createArgs(['issueNewId() is not implemented yet.']),
+ ))),
+ ];
+
+ return $method;
+ }
+}
diff --git a/packages/upgrade/src/Tempest321/UpdateSessionManagerImplementationsRector.php b/packages/upgrade/src/Tempest321/UpdateSessionManagerImplementationsRector.php
new file mode 100644
index 0000000000..d7c3b845be
--- /dev/null
+++ b/packages/upgrade/src/Tempest321/UpdateSessionManagerImplementationsRector.php
@@ -0,0 +1,63 @@
+implements, fn (Name $name) => $this->isName($name, SessionManager::class))) {
+ return null;
+ }
+
+ if ($node->getMethod('regenerate') instanceof ClassMethod) {
+ return null;
+ }
+
+ $node->stmts[] = $this->createRegenerateMethod();
+
+ return $node;
+ }
+
+ private function createRegenerateMethod(): ClassMethod
+ {
+ $method = $this->nodeFactory->createPublicMethod('regenerate');
+
+ $method->params[] = $this->nodeFactory->createParamFromNameAndType('session', new ObjectType(Session::class));
+ $method->returnType = new Identifier('void');
+ $method->stmts = [
+ new Expression(new Throw_(new New_(
+ new FullyQualified(BadMethodCallException::class),
+ $this->nodeFactory->createArgs(['regenerate() is not implemented yet.']),
+ ))),
+ ];
+
+ return $method;
+ }
+}
diff --git a/packages/upgrade/tests/Tempest321/Fixtures/AliasedSessionImplementations.input.php b/packages/upgrade/tests/Tempest321/Fixtures/AliasedSessionImplementations.input.php
new file mode 100644
index 0000000000..52ecf64d88
--- /dev/null
+++ b/packages/upgrade/tests/Tempest321/Fixtures/AliasedSessionImplementations.input.php
@@ -0,0 +1,41 @@
+ new RectorTester(__DIR__ . '/tempest321_rector.php');
+ }
+
+ #[Test]
+ public function session_implementation_methods_are_added(): void
+ {
+ $this->rector
+ ->runFixture(__DIR__ . '/Fixtures/SessionImplementations.input.php')
+ ->assertContains('public function regenerate(Session $session): void')
+ ->assertContains('public function issueNewId(): SessionId')
+ ->assertContains("throw new BadMethodCallException('regenerate() is not implemented yet.');")
+ ->assertContains("throw new BadMethodCallException('issueNewId() is not implemented yet.');");
+ }
+
+ #[Test]
+ public function aliased_session_implementations_are_updated(): void
+ {
+ $this->rector
+ ->runFixture(__DIR__ . '/Fixtures/AliasedSessionImplementations.input.php')
+ ->assertContains('public function regenerate(Session $session): void')
+ ->assertContains('public function issueNewId(): SessionId');
+ }
+
+ #[Test]
+ public function existing_session_methods_are_not_overwritten(): void
+ {
+ $this->assertSame(
+ '',
+ $this->rector
+ ->runFixture(__DIR__ . '/Fixtures/ExistingSessionImplementations.input.php')
+ ->actual,
+ );
+ }
+}
diff --git a/packages/upgrade/tests/Tempest321/tempest321_rector.php b/packages/upgrade/tests/Tempest321/tempest321_rector.php
new file mode 100644
index 0000000000..1db881515c
--- /dev/null
+++ b/packages/upgrade/tests/Tempest321/tempest321_rector.php
@@ -0,0 +1,11 @@
+withSets([TempestSetList::TEMPEST_321])
+ ->withCache(cacheClass: MemoryCacheStorage::class);
diff --git a/packages/view/composer.json b/packages/view/composer.json
index ee1c114a32..4e11866e82 100644
--- a/packages/view/composer.json
+++ b/packages/view/composer.json
@@ -9,8 +9,8 @@
"tempest/container": "3.x-dev",
"tempest/validation": "3.x-dev",
"tempest/clock": "3.x-dev",
- "league/commonmark": "^2.7",
- "symfony/cache": "^7.3|^8.0"
+ "symfony/cache": "^7.3|^8.0",
+ "tempest/markdown": "^1.3"
},
"autoload": {
"files": [
diff --git a/packages/view/src/Components/x-markdown.view.php b/packages/view/src/Components/x-markdown.view.php
index 56c75b8bb7..d34df0a4fd 100644
--- a/packages/view/src/Components/x-markdown.view.php
+++ b/packages/view/src/Components/x-markdown.view.php
@@ -4,14 +4,14 @@
* @var string|null $content The markdown content from a variable
*/
-use League\CommonMark\MarkdownConverter;
+use Tempest\Markdown\Markdown;
use Tempest\View\Slot;
use function Tempest\Container\get;
$content ??= $slots[Slot::DEFAULT]->content ?? '';
-$markdown = get(MarkdownConverter::class);
-$parsed = $markdown->convert($content)->getContent();
+$markdown = get(Markdown::class);
+$parsed = $markdown->parse($content)->html;
?>
{!! $parsed !!}
diff --git a/packages/view/src/MarkdownInitializer.php b/packages/view/src/MarkdownInitializer.php
index 88de452853..6f53ff0186 100644
--- a/packages/view/src/MarkdownInitializer.php
+++ b/packages/view/src/MarkdownInitializer.php
@@ -2,18 +2,16 @@
namespace Tempest\View;
-use League\CommonMark\Environment\Environment;
-use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
-use League\CommonMark\MarkdownConverter;
use Tempest\Container\Container;
use Tempest\Container\Initializer;
use Tempest\Container\Singleton;
+use Tempest\Markdown\Markdown;
final class MarkdownInitializer implements Initializer
{
#[Singleton]
- public function initialize(Container $container): MarkdownConverter
+ public function initialize(Container $container): Markdown
{
- return new MarkdownConverter(new Environment()->addExtension(new CommonMarkCoreExtension()));
+ return new Markdown();
}
}
diff --git a/packages/vite-plugin-tempest/package.json b/packages/vite-plugin-tempest/package.json
index 1a11abe583..918c9c9f90 100644
--- a/packages/vite-plugin-tempest/package.json
+++ b/packages/vite-plugin-tempest/package.json
@@ -1,7 +1,7 @@
{
"name": "vite-plugin-tempest",
"type": "module",
- "version": "3.19.2",
+ "version": "3.21.0",
"author": "Enzo Innocenzi",
"license": "MIT",
"sideEffects": false,
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 65e3eb267f..e7eeef7af6 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -21,10 +21,6 @@ parameters:
identifier: property.phpDocType
paths:
- packages/database/src/Builder/*
- -
- identifier: class.notFound
- paths:
- - packages/database/src/Builder/*
# End of database ignores
-
diff --git a/rector.php b/rector.php
index 6d49c1f9bf..3cf9dcd152 100644
--- a/rector.php
+++ b/rector.php
@@ -9,7 +9,6 @@
use Rector\Config\RectorConfig;
use Rector\EarlyReturn\Rector\Return_\ReturnBinaryOrToEarlyReturnRector;
use Rector\Php55\Rector\String_\StringClassNameToClassConstantRector;
-use Rector\Php70\Rector\StaticCall\StaticCallOnNonStaticToInstanceCallRector;
use Rector\Php74\Rector\Property\RestoreDefaultNullToNullableTypePropertyRector;
use Rector\Php81\Rector\Array_\ArrayToFirstClassCallableRector;
use Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector;
@@ -47,7 +46,6 @@
ReadOnlyPropertyRector::class,
AddSensitiveParameterAttributeRector::class,
RestoreDefaultNullToNullableTypePropertyRector::class,
- StaticCallOnNonStaticToInstanceCallRector::class,
EncapsedStringsToSprintfRector::class,
PrivatizeFinalClassMethodRector::class,
IssetOnPropertyObjectToPropertyExistsRector::class,
diff --git a/src/Tempest/Framework/Commands/MetaViewComponentCommand.php b/src/Tempest/Framework/Commands/MetaViewComponentCommand.php
index ef9c5b0e14..b43d889a62 100644
--- a/src/Tempest/Framework/Commands/MetaViewComponentCommand.php
+++ b/src/Tempest/Framework/Commands/MetaViewComponentCommand.php
@@ -6,7 +6,6 @@
use Tempest\Console\ConsoleCommand;
use Tempest\Console\HasConsole;
use Tempest\Support\Arr\ImmutableArray;
-use Tempest\Support\Str\ImmutableString;
use Tempest\View\Slot;
use Tempest\View\ViewComponent;
use Tempest\View\ViewConfig;
@@ -81,11 +80,17 @@ private function resolveSlots(ViewComponent $viewComponent): ImmutableArray
private function resolveVariables(ViewComponent $viewComponent): ImmutableArray
{
return str($viewComponent->contents)
- ->matchAll('/^\s*\*\s*@var.*$/m')
- ->map(fn (array $matches) => str($matches[0]))
- ->map(fn (ImmutableString $line) => $line->replaceRegex('/^\s*\*\s*@var\s*/', ''))
- ->map(fn (ImmutableString $line) => $line->trim())
- ->map(fn (ImmutableString $line) => $line->explode(limit: 3))
+ ->matchAll(
+ pattern: '/(?:^\s*\*|\/\*\*)[ \t]*@var[ \t]+(?[^\r\n]*?)[ \t]*(?:\*\/(?:\s*(?\$\w+)\s*=(?!=))?|\r?$)/m',
+ matches: ['declaration', 'assignee'],
+ )
+ ->map(fn (array $matches) => [
+ 'parts' => str($matches['declaration'])->explode(limit: 3),
+ 'assignee' => $matches['assignee'] ?? null,
+ ])
+ // A one-line `@var` right before an assignment types a local variable, not an attribute.
+ ->filter(fn (array $match) => $match['assignee'] === null || $match['assignee'] !== ($match['parts'][1] ?? null))
+ ->map(fn (array $match) => $match['parts'])
->mapWithKeys(
fn (ImmutableArray $parts) => yield $parts[1] => [
'type' => $parts[0],
diff --git a/tests/Benchmark/Generation/ClassGenerationBench.php b/tests/Benchmark/Generation/ClassGenerationBench.php
new file mode 100644
index 0000000000..4e882e1f71
--- /dev/null
+++ b/tests/Benchmark/Generation/ClassGenerationBench.php
@@ -0,0 +1,66 @@
+setFinal();
+ $class->setReadOnly();
+ $class->addImplement('Tempest\\Database\\MigratesUp');
+ $class->addMethod(
+ 'up',
+ body: "return new \\Tempest\\Database\\QueryStatements\\CreateTableStatement('books')\n ->primary()\n ->text('title');",
+ returnType: 'Tempest\\Database\\QueryStatement',
+ );
+ $class->addMethod('getName', body: "return '2026-10-27_create_books_table';", returnType: 'string');
+ $class->print();
+ }
+
+ #[Iterations(5)]
+ #[Revs(200)]
+ #[Warmup(2)]
+ public function benchManipulateStub(): void
+ {
+ $class = new ClassManipulator(__DIR__ . '/../../../packages/auth/src/Installer/oauth/OAuthControllerStub.php');
+ $class->setNamespace('App\\Authentication\\OAuth');
+ $class->setClassName('GithubController');
+ $class->removeClassAttribute(SkipDiscovery::class);
+ $class->print();
+ }
+
+ #[Iterations(5)]
+ #[Revs(20)]
+ #[Warmup(1)]
+ public function benchManipulateLargeClass(): void
+ {
+ $class = new ClassManipulator(__DIR__ . '/../../../packages/container/src/GenericContainer.php');
+ $class->setNamespace('App\\Container');
+ $class->setClassName('Container');
+ $class->print();
+ }
+
+ #[Iterations(5)]
+ #[Revs(200)]
+ #[Warmup(2)]
+ public function benchDetectStubFileTypes(): void
+ {
+ StubFile::from(__DIR__ . '/../../../packages/console/src/Stubs/CommandStub.php');
+ StubFile::from(__DIR__ . '/../../../packages/console/src/Stubs/database.config.stub.php');
+ }
+}
diff --git a/tests/Fixtures/Config/database.mysql.php b/tests/Fixtures/Config/database.mysql.php
index 27ded34a8b..03ca40fa8d 100644
--- a/tests/Fixtures/Config/database.mysql.php
+++ b/tests/Fixtures/Config/database.mysql.php
@@ -7,5 +7,6 @@
use function Tempest\env;
return new MysqlConfig(
+ host: '127.0.0.1',
database: 'app' . env('TEST_TOKEN', ''),
);
diff --git a/tests/Integration/Auth/Authentication/SessionAuthenticatorTest.php b/tests/Integration/Auth/Authentication/SessionAuthenticatorTest.php
index b061e602b3..ae325483ed 100644
--- a/tests/Integration/Auth/Authentication/SessionAuthenticatorTest.php
+++ b/tests/Integration/Auth/Authentication/SessionAuthenticatorTest.php
@@ -23,6 +23,7 @@
use Tempest\Http\Session\Config\FileSessionConfig;
use Tempest\Http\Session\Managers\FileSessionManager;
use Tempest\Http\Session\Session;
+use Tempest\Http\Session\SessionIdResolver;
use Tempest\Http\Session\SessionManager;
use Tempest\Support\Filesystem;
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
@@ -47,6 +48,7 @@ protected function configure(): void
$this->container->singleton(SessionManager::class, fn () => new FileSessionManager(
$this->container->get(Clock::class),
$this->container->get(FileSessionConfig::class),
+ $this->container->get(SessionIdResolver::class),
));
$this->database->migrate(CreateMigrationsTable::class, CreateUsersTableMigration::class, CreateApiKeysTableMigration::class);
diff --git a/tests/Integration/CommandBus/RedisCommandRepositoryTest.php b/tests/Integration/CommandBus/RedisCommandRepositoryTest.php
index 382645cd03..adf6fe0be9 100644
--- a/tests/Integration/CommandBus/RedisCommandRepositoryTest.php
+++ b/tests/Integration/CommandBus/RedisCommandRepositoryTest.php
@@ -4,6 +4,7 @@
namespace Tests\Tempest\Integration\CommandBus;
+use Deprecated;
use PHPUnit\Framework\Attributes\PostCondition;
use PHPUnit\Framework\Attributes\PreCondition;
use PHPUnit\Framework\Attributes\Test;
@@ -149,10 +150,8 @@ public function skips_commands_that_cannot_be_unserialized(): void
$this->assertArrayNotHasKey($corrupted, $pending);
}
- /**
- * @deprecated Remove in 4.0, together with the migration itself.
- */
#[Test]
+ #[Deprecated(message: 'Remove in 4.0, together with the migration itself.')]
public function migrates_commands_stored_under_their_own_key(): void
{
$this->redis->command('SET', 'command:pending:' . ($pending = uuid()), serialize($command = new MyCommand()));
@@ -167,10 +166,8 @@ public function migrates_commands_stored_under_their_own_key(): void
$this->assertSame(1, (int) $this->redis->command('HEXISTS', 'command:failed', $failed));
}
- /**
- * @deprecated Remove in 4.0, together with the migration itself.
- */
#[Test]
+ #[Deprecated(message: 'Remove in 4.0, together with the migration itself.')]
public function migrating_leaves_commands_already_stored_in_the_hash_alone(): void
{
$this->repository->store($uuid = uuid(), $command = new MyCommand());
@@ -179,10 +176,8 @@ public function migrating_leaves_commands_already_stored_in_the_hash_alone(): vo
$this->assertEquals([$uuid => $command], $this->repository->getPendingCommands());
}
- /**
- * @deprecated Remove in 4.0, together with the migration itself.
- */
#[Test]
+ #[Deprecated(message: 'Remove in 4.0, together with the migration itself.')]
public function migrating_only_scans_the_keyspace_once(): void
{
$this->assertSame(0, $this->repository->migrateStoredCommands());
diff --git a/tests/Integration/Cryptography/TimelockTest.php b/tests/Integration/Cryptography/TimelockTest.php
index 5619a2ff67..64462215ea 100644
--- a/tests/Integration/Cryptography/TimelockTest.php
+++ b/tests/Integration/Cryptography/TimelockTest.php
@@ -17,7 +17,7 @@ public function uses_mocked_clock(): void
$ms = $clock->timestamp()->getMilliseconds();
$timelock->invoke(
- callback: fn () => null,
+ callback: fn () => usleep(1_000),
duration: Duration::milliseconds(10_000),
);
$elapsed = $clock->timestamp()->getMilliseconds() - $ms;
diff --git a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php
index 9df2abe77f..8dbc711042 100644
--- a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php
+++ b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php
@@ -316,4 +316,26 @@ public function insert_skips_has_one_through_property(): void
$this->assertSameWithoutBackticks($expected, $query->compile());
$this->assertSame(['php'], $query->bindings);
}
+
+ #[Test]
+ public function insert_with_zero_id_falls_back_to_last_insert_id(): void
+ {
+ $this->database->migrate(
+ CreateMigrationsTable::class,
+ CreatePublishersTable::class,
+ CreateAuthorTable::class,
+ CreateBookTable::class,
+ );
+
+ $id = query(Book::class)
+ ->insert(['id' => 0, 'title' => 'Zero ID Book'])
+ ->execute();
+
+ $this->assertInstanceOf(PrimaryKey::class, $id);
+
+ $book = query(Book::class)->get($id);
+
+ $this->assertNotNull($book);
+ $this->assertSame('Zero ID Book', $book->title);
+ }
}
diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php
index caa78ef775..841d44524e 100644
--- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php
+++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php
@@ -10,6 +10,7 @@
use Tempest\Database\Direction;
use Tempest\Database\IsDatabaseModel;
use Tempest\Database\Migrations\CreateMigrationsTable;
+use Tempest\Database\PrimaryKey;
use Tempest\Database\QueryExecuted;
use Tempest\Database\Table;
use Tests\Tempest\Fixtures\Events\QueryLogger;
@@ -23,6 +24,7 @@
use Tests\Tempest\Fixtures\Modules\Books\Models\AuthorType;
use Tests\Tempest\Fixtures\Modules\Books\Models\Book;
use Tests\Tempest\Fixtures\Modules\Books\Models\Chapter;
+use Tests\Tempest\Fixtures\Modules\Books\Models\Publisher;
use Tests\Tempest\Fixtures\Modules\Books\Models\Tag;
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
@@ -255,12 +257,12 @@ public function order_by_raw_shorthand(): void
public function order_by_sql_generation(): void
{
$this->assertSameWithoutBackticks(
- expected: 'SELECT * FROM `books` ORDER BY `title` ASC',
+ expected: 'SELECT * FROM `books` ORDER BY `books`.`title` ASC',
actual: query('books')->select()->orderBy('title')->compile(),
);
$this->assertSameWithoutBackticks(
- expected: 'SELECT * FROM `books` ORDER BY `title` DESC',
+ expected: 'SELECT * FROM `books` ORDER BY `books`.`title` DESC',
actual: query('books')->select()->orderBy('title', Direction::DESC)->compile(),
);
@@ -270,6 +272,57 @@ public function order_by_sql_generation(): void
);
}
+ #[Test]
+ public function order_by_qualifies_bare_field_with_base_table_and_leaves_dotted_fields_alone(): void
+ {
+ $this->assertSameWithoutBackticks(
+ expected: 'SELECT * FROM `authors` ORDER BY `authors`.`name` ASC',
+ actual: query('authors')->select()->orderBy('name')->compile(),
+ );
+
+ $this->assertSameWithoutBackticks(
+ expected: 'SELECT * FROM `authors` ORDER BY `publishers`.`name` ASC',
+ actual: query('authors')->select()->orderBy('publishers.name')->compile(),
+ );
+ }
+
+ #[Test]
+ public function order_by_with_joined_table_uses_base_table_column_for_ambiguous_bare_field(): void
+ {
+ $this->database->migrate(
+ CreateMigrationsTable::class,
+ CreatePublishersTable::class,
+ CreateAuthorTable::class,
+ CreateBookTable::class,
+ );
+
+ query(Publisher::class)
+ ->insert(id: new PrimaryKey(1), name: 'Alpha Publisher', description: 'd')
+ ->execute();
+ query(Publisher::class)
+ ->insert(id: new PrimaryKey(2), name: 'Zeta Publisher', description: 'd')
+ ->execute();
+ query(Author::class)
+ ->insert(id: new PrimaryKey(1), name: 'Beta Author', type: AuthorType::A, publisher_id: new PrimaryKey(2))
+ ->execute();
+ query(Author::class)
+ ->insert(id: new PrimaryKey(2), name: 'Alpha Author', type: AuthorType::A, publisher_id: new PrimaryKey(1))
+ ->execute();
+
+ // `name` exists on both `authors` and the joined `publishers`; the query
+ // must order by the base table (`authors`) column instead of failing.
+ $authors = query(Author::class)
+ ->select()
+ ->join('LEFT JOIN publishers ON publishers.id = authors.publisher_id')
+ ->orderBy('name')
+ ->all();
+
+ $this->assertSame(
+ ['Alpha Author', 'Beta Author'],
+ array_map(fn (Author $author) => $author->name, $authors),
+ );
+ }
+
#[Test]
public function limit(): void
{
diff --git a/tests/Integration/Database/MultiDatabaseTest.php b/tests/Integration/Database/MultiDatabaseTest.php
index 046b8231b9..8efd7f6b7c 100644
--- a/tests/Integration/Database/MultiDatabaseTest.php
+++ b/tests/Integration/Database/MultiDatabaseTest.php
@@ -167,6 +167,7 @@ public function with_different_dialects(): void
));
$this->container->config(new MysqlConfig(
+ host: '127.0.0.1',
database: 'app' . env('TEST_TOKEN', ''),
tag: 'mysql-main',
));
diff --git a/tests/Integration/Database/QueryStatements/IntegerStatementTest.php b/tests/Integration/Database/QueryStatements/IntegerStatementTest.php
new file mode 100644
index 0000000000..a3e9b9396a
--- /dev/null
+++ b/tests/Integration/Database/QueryStatements/IntegerStatementTest.php
@@ -0,0 +1,81 @@
+primary()
+ ->integer('small', unsigned: true, size: DatabaseIntegerSize::SMALL)
+ ->integer('regular', unsigned: true)
+ ->integer('big', unsigned: true, size: DatabaseIntegerSize::BIG)
+ ->integer('nullable_with_default', unsigned: true, nullable: true, default: 1);
+ }
+ };
+
+ $this->database->migrate(CreateMigrationsTable::class, $migration);
+
+ $this->expectNotToPerformAssertions();
+ }
+
+ #[Test]
+ #[TestWith([DatabaseDialect::MYSQL, true])]
+ #[TestWith([DatabaseDialect::SQLITE, true])]
+ #[TestWith([DatabaseDialect::POSTGRESQL, false])]
+ public function unsigned_is_only_emitted_on_dialects_that_have_it(DatabaseDialect $dialect, bool $expected): void
+ {
+ $statement = new IntegerStatement('votes', unsigned: true)->compile($dialect);
+
+ if ($expected) {
+ $this->assertStringContainsString('UNSIGNED', $statement);
+ } else {
+ $this->assertStringNotContainsString('UNSIGNED', $statement);
+ }
+ }
+
+ #[Test]
+ #[TestWith([DatabaseDialect::MYSQL])]
+ #[TestWith([DatabaseDialect::POSTGRESQL])]
+ public function the_size_chooses_the_type(DatabaseDialect $dialect): void
+ {
+ $this->assertStringContainsString(
+ 'SMALLINT',
+ new IntegerStatement('n', size: DatabaseIntegerSize::SMALL)->compile($dialect),
+ );
+
+ $this->assertStringContainsString(
+ 'BIGINT',
+ new IntegerStatement('n', size: DatabaseIntegerSize::BIG)->compile($dialect),
+ );
+
+ // A byte count is rounded up to the size that can hold it.
+ $this->assertStringContainsString(
+ 'BIGINT',
+ new IntegerStatement('n', size: 8)->compile($dialect),
+ );
+ }
+}
diff --git a/tests/Integration/Database/UuidPrimaryKeyTest.php b/tests/Integration/Database/UuidPrimaryKeyTest.php
index 43cdf86a9c..a3979439c2 100644
--- a/tests/Integration/Database/UuidPrimaryKeyTest.php
+++ b/tests/Integration/Database/UuidPrimaryKeyTest.php
@@ -5,12 +5,15 @@
namespace Tests\Tempest\Integration\Database;
use PHPUnit\Framework\Attributes\Test;
+use Tempest\Database\BelongsToMany;
+use Tempest\Database\HasMany;
use Tempest\Database\IsDatabaseModel;
use Tempest\Database\MigratesUp;
use Tempest\Database\Migrations\CreateMigrationsTable;
use Tempest\Database\PrimaryKey;
use Tempest\Database\QueryStatement;
use Tempest\Database\QueryStatements\CreateTableStatement;
+use Tempest\Database\QueryStatements\OnDelete;
use Tempest\Database\Table;
use Tempest\Database\Uuid;
use Tempest\Support\Random;
@@ -124,6 +127,72 @@ public function uuid_primary_key_without_is_database_model_trait(): void
$this->assertNotNull($retrieved);
$this->assertTrue($mage->uuid->equals($retrieved->uuid));
}
+
+ #[Test]
+ public function uuid_primary_key_generated_for_iterable_insert(): void
+ {
+ $this->database->migrate(CreateMigrationsTable::class, CreateUuidRolesTableMigration::class);
+
+ $id = query(UuidRole::class)->insert(name: 'admin')->execute();
+
+ $this->assertInstanceOf(PrimaryKey::class, $id);
+ $this->assertTrue(Random\is_uuid($id->value));
+
+ $role = query(UuidRole::class)->get($id);
+
+ $this->assertNotNull($role);
+ $this->assertSame('admin', $role->name);
+ }
+
+ #[Test]
+ public function uuid_primary_key_belongs_to_many_pivot_uses_generated_uuid(): void
+ {
+ $this->database->migrate(
+ CreateMigrationsTable::class,
+ CreateUuidUsersTableMigration::class,
+ CreateUuidRolesTableMigration::class,
+ CreateUuidUserRoleTableMigration::class,
+ );
+
+ $role = query(UuidRole::class)->create(name: 'admin');
+
+ $user = query(UuidUser::class)->create(
+ name: 'Frieren',
+ roles: [$role],
+ );
+
+ $this->assertTrue(Random\is_uuid($user->id->value));
+
+ $pivotRows = query('uuid_user_role')->select()->all();
+
+ $this->assertCount(1, $pivotRows);
+ $this->assertSame($user->id->value, $pivotRows[0]['uuid_user_id']);
+ $this->assertSame($role->id->value, $pivotRows[0]['uuid_role_id']);
+ }
+
+ #[Test]
+ public function uuid_primary_key_has_many_uses_generated_uuid_as_foreign_key(): void
+ {
+ $this->database->migrate(
+ CreateMigrationsTable::class,
+ CreateUuidUsersTableMigration::class,
+ CreateUuidPostsTableMigration::class,
+ );
+
+ $user = query(UuidUser::class)->create(
+ name: 'Frieren',
+ posts: [
+ ['title' => 'Grimoire Notes'],
+ ],
+ );
+
+ $this->assertTrue(Random\is_uuid($user->id->value));
+
+ $posts = query('uuid_posts')->select()->all();
+
+ $this->assertCount(1, $posts);
+ $this->assertSame($user->id->value, $posts[0]['uuid_user_id']);
+ }
}
#[Table('model')]
@@ -164,3 +233,98 @@ public function up(): QueryStatement
->text('race');
}
}
+
+final class UuidUser
+{
+ use IsDatabaseModel;
+
+ #[Uuid]
+ public PrimaryKey $id;
+
+ /** @var \Tests\Tempest\Integration\Database\UuidRole[] */
+ #[BelongsToMany(pivot: 'uuid_user_role')]
+ public array $roles = [];
+
+ /** @var \Tests\Tempest\Integration\Database\UuidPost[] */
+ #[HasMany]
+ public array $posts = [];
+
+ public function __construct(
+ public string $name,
+ ) {}
+}
+
+final class UuidRole
+{
+ use IsDatabaseModel;
+
+ #[Uuid]
+ public PrimaryKey $id;
+
+ public function __construct(
+ public string $name,
+ ) {}
+}
+
+final class UuidPost
+{
+ use IsDatabaseModel;
+
+ #[Uuid]
+ public PrimaryKey $id;
+
+ public ?PrimaryKey $uuid_user_id = null;
+
+ public function __construct(
+ public string $title,
+ ) {}
+}
+
+final class CreateUuidUsersTableMigration implements MigratesUp
+{
+ public string $name = '100_create_uuid_users';
+
+ public function up(): QueryStatement
+ {
+ return new CreateTableStatement('uuid_users')
+ ->uuid()
+ ->varchar('name');
+ }
+}
+
+final class CreateUuidRolesTableMigration implements MigratesUp
+{
+ public string $name = '101_create_uuid_roles';
+
+ public function up(): QueryStatement
+ {
+ return new CreateTableStatement('uuid_roles')
+ ->uuid()
+ ->varchar('name');
+ }
+}
+
+final class CreateUuidUserRoleTableMigration implements MigratesUp
+{
+ public string $name = '102_create_uuid_user_role';
+
+ public function up(): QueryStatement
+ {
+ return new CreateTableStatement('uuid_user_role')
+ ->belongsToUuid('uuid_user_role.uuid_user_id', 'uuid_users.id', onDelete: OnDelete::CASCADE)
+ ->belongsToUuid('uuid_user_role.uuid_role_id', 'uuid_roles.id', onDelete: OnDelete::CASCADE);
+ }
+}
+
+final class CreateUuidPostsTableMigration implements MigratesUp
+{
+ public string $name = '103_create_uuid_posts';
+
+ public function up(): QueryStatement
+ {
+ return new CreateTableStatement('uuid_posts')
+ ->uuid()
+ ->varchar('title')
+ ->foreignUuid('uuid_user_id', constrainedOn: 'uuid_users', onDelete: OnDelete::CASCADE, nullable: true);
+ }
+}
diff --git a/tests/Integration/Framework/Commands/MetaViewComponentCommandTest.php b/tests/Integration/Framework/Commands/MetaViewComponentCommandTest.php
index 0b4621309b..b4b80f655d 100644
--- a/tests/Integration/Framework/Commands/MetaViewComponentCommandTest.php
+++ b/tests/Integration/Framework/Commands/MetaViewComponentCommandTest.php
@@ -61,4 +61,51 @@ public function show_meta_for_view_component(): void
],
JSON);
}
+
+ #[Test]
+ public function show_variables_declared_in_one_line_docblock(): void
+ {
+ $this->console
+ ->call('meta:view-component x-with-variable')
+ ->assertSuccess()
+ ->assertSee(<<<'JSON'
+ "variables": [
+ {
+ "type": "string",
+ "name": "$variable",
+ "attributeName": "variable",
+ "description": null
+ }
+ ]
+ JSON);
+ }
+
+ #[Test]
+ public function show_description_of_one_line_docblock_variable(): void
+ {
+ $this->console
+ ->call('meta:view-component x-submit')
+ ->assertSuccess()
+ ->assertSee(<<<'JSON'
+ "variables": [
+ {
+ "type": "null|string",
+ "name": "$label",
+ "attributeName": "label",
+ "description": "The submit button's label"
+ }
+ ]
+ JSON);
+ }
+
+ #[Test]
+ public function ignore_one_line_docblock_typing_a_local_assignment(): void
+ {
+ $this->console
+ ->call('meta:view-component x-input')
+ ->assertSuccess()
+ ->assertSee('"name": "$default",')
+ ->assertNotSee('$formSession')
+ ->assertNotSee('$validator');
+ }
}
diff --git a/tests/Integration/Generation/StubFileGeneratorTest.php b/tests/Integration/Generation/StubFileGeneratorTest.php
new file mode 100644
index 0000000000..f0737ae1a9
--- /dev/null
+++ b/tests/Integration/Generation/StubFileGeneratorTest.php
@@ -0,0 +1,126 @@
+installer->configure(
+ $this->internalStorage . '/install',
+ new Psr4Namespace('App\\', $this->internalStorage . '/install/App'),
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ $this->installer->clean();
+
+ parent::tearDown();
+ }
+
+ #[Test]
+ #[DataProvider('production_stubs')]
+ public function generates_class_files_from_production_stubs(string $stub, array $replacements = [], array $manipulations = []): void
+ {
+ $targetPath = $this->installer->path('App/Generated/GeneratedClass.php');
+
+ $this->container->get(StubFileGenerator::class)->generateClassFile(
+ stubFile: StubFile::from($stub),
+ targetPath: $targetPath,
+ replacements: $replacements,
+ manipulations: [
+ static fn (ClassManipulator $class) => $class->removeClassAttribute(SkipDiscovery::class),
+ ...$manipulations,
+ ],
+ );
+
+ $code = $this->installer->get('App/Generated/GeneratedClass.php');
+
+ $this->assertNotEmpty(token_get_all($code, TOKEN_PARSE));
+
+ if (PHP_OS_FAMILY === 'Windows') {
+ $this->markTestSkipped('Snapshots are not supported on Windows.');
+ }
+
+ $this->assertMatchesSnapshot($code);
+ }
+
+ public static function production_stubs(): iterable
+ {
+ yield 'command' => [CommandStub::class, ['dummy-command-slug' => 'generated-class']];
+ yield 'generator command' => [GeneratorCommandStub::class, ['dummy-command-slug' => 'generated-class']];
+ yield 'console middleware' => [ConsoleMiddlewareStub::class];
+ yield 'http middleware' => [HttpMiddlewareStub::class];
+ yield 'event bus middleware' => [EventBusMiddlewareStub::class];
+ yield 'command bus middleware' => [CommandBusMiddlewareStub::class];
+ yield 'initializer' => [InitializerStub::class];
+ yield 'singleton initializer' => [
+ InitializerStub::class,
+ [],
+ [static fn (ClassManipulator $class) => $class->addMethodAttribute('initialize', Singleton::class)],
+ ];
+ yield 'discovery' => [DiscoveryStub::class];
+ yield 'controller' => [ControllerStub::class, ['dummy-path' => 'books', 'dummy-view' => 'books.view.php']];
+ yield 'database model' => [DatabaseModelStub::class];
+ yield 'object migration' => [ObjectMigrationStub::class, ['dummy-date' => '2026-10-27', 'dummy-migration-name' => 'create_books_table', 'dummy-table-name' => 'books']];
+ yield 'object alter migration' => [
+ ObjectAlterMigrationStub::class,
+ ['dummy-date' => '2026-10-27', 'dummy-migration-name' => 'alter_books_table', 'dummy-table-name' => 'books'],
+ ];
+ yield 'up migration' => [UpMigrationStub::class, ['dummy-date' => '2026-10-27', 'dummy-migration-name' => 'create_books_table', 'dummy-table-name' => 'books']];
+ yield 'up alter migration' => [UpAlterMigrationStub::class, ['dummy-date' => '2026-10-27', 'dummy-migration-name' => 'alter_books_table', 'dummy-table-name' => 'books']];
+ yield 'request' => [RequestStub::class];
+ yield 'response' => [ResponseStub::class];
+ yield 'view' => [ViewStub::class, ['dummy.view.php' => 'generated-class.view.php']];
+ yield 'users table migration' => [__DIR__ . '/../../../packages/auth/src/Installer/basic-user/CreateUsersTableMigration.stub.php'];
+ yield 'user model' => [__DIR__ . '/../../../packages/auth/src/Installer/basic-user/UserModel.stub.php'];
+ yield 'authentication decorator' => [__DIR__ . '/../../../packages/auth/src/Installer/basic-user/MustBeAuthenticated.stub.php'];
+ yield 'authentication middleware' => [__DIR__ . '/../../../packages/auth/src/Installer/basic-user/MustBeAuthenticatedMiddleware.stub.php'];
+ yield 'oauth controller' => [OAuthControllerStub::class];
+ yield 'commands table migration' => [CreateCommandsTable::class];
+ yield 'sessions table migration' => [CreateSessionsTable::class];
+ yield 'cleanup sessions command' => [CleanupSessionsCommand::class];
+ }
+}
diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set authentication decorator__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set authentication decorator__1.txt
new file mode 100644
index 0000000000..f836ecae0f
--- /dev/null
+++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set authentication decorator__1.txt
@@ -0,0 +1,19 @@
+middleware[] = MustBeAuthenticatedMiddleware::class;
+
+ return $route;
+ }
+}
diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set authentication middleware__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set authentication middleware__1.txt
new file mode 100644
index 0000000000..aabcae43a2
--- /dev/null
+++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set authentication middleware__1.txt
@@ -0,0 +1,26 @@
+authenticator->current()) {
+ return new Forbidden();
+ }
+
+ return $next($request);
+ }
+}
diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set cleanup sessions command__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set cleanup sessions command__1.txt
new file mode 100644
index 0000000000..b02bb86a44
--- /dev/null
+++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set cleanup sessions command__1.txt
@@ -0,0 +1,35 @@
+eventBus->listen($this->onSessionDeleted(...));
+ $this->sessionManager->deleteExpiredSessions();
+ }
+
+ private function onSessionDeleted(SessionDeleted $event): void
+ {
+ $this->console->keyValue((string) $event->id, "");
+ }
+}
diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set command bus middleware__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set command bus middleware__1.txt
new file mode 100644
index 0000000000..68cac1bb4a
--- /dev/null
+++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set command bus middleware__1.txt
@@ -0,0 +1,18 @@
+console->success('Done!');
+ }
+}
diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set commands table migration__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set commands table migration__1.txt
new file mode 100644
index 0000000000..1333865a93
--- /dev/null
+++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set commands table migration__1.txt
@@ -0,0 +1,22 @@
+uuid('id')
+ ->text('payload')
+ ->datetime('failed_at', nullable: true);
+ }
+}
diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set console middleware__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set console middleware__1.txt
new file mode 100644
index 0000000000..c9fb97fa99
--- /dev/null
+++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set console middleware__1.txt
@@ -0,0 +1,23 @@
+implements('MyClass::class')) {
+ return;
+ }
+
+ $this->discoveryItems->add($location, $class);
+ }
+
+ /**
+ * @mago-expect lint:no-empty-loop
+ */
+ public function apply(): void
+ {
+ foreach ($this->discoveryItems as $class) {
+ // Do something with the discovered class
+ }
+ }
+}
diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set event bus middleware__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set event bus middleware__1.txt
new file mode 100644
index 0000000000..b57b2bab6d
--- /dev/null
+++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set event bus middleware__1.txt
@@ -0,0 +1,18 @@
+getSuggestedPath($className);
+ $targetPath = $this->promptTargetPath($suggestedPath);
+ $shouldOverride = $this->askForOverride($targetPath);
+
+ $this->stubFileGenerator->generateClassFile(
+ stubFile: StubFile::from('MyStubClass::class'),
+ targetPath: $targetPath,
+ shouldOverride: $shouldOverride,
+ manipulations: [
+ fn (ClassManipulator $class) => $class->removeClassAttribute(SkipDiscovery::class),
+ ],
+ );
+
+ $this->console->writeln();
+ $this->console->success(sprintf('File successfully created at .', $targetPath));
+ }
+}
diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set http middleware__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set http middleware__1.txt
new file mode 100644
index 0000000000..0d0c2ddf2d
--- /dev/null
+++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set http middleware__1.txt
@@ -0,0 +1,20 @@
+oauth->createRedirect();
+ }
+
+ #[Get('callback-route')]
+ public function callback(Request $request): Redirect
+ {
+ // TODO: implement, the code below is an example
+
+ $this->oauth->authenticate(
+ request: $request,
+ map: fn (OAuthUser $user): Authenticatable => query('user-model-fqcn')->updateOrCreate([
+ 'provider_db_column' => $user->id,
+ ], [
+ 'provider_db_column' => $user->id,
+ 'username' => $user->nickname,
+ 'email' => $user->email,
+ ]),
+ );
+
+ return new Redirect('/');
+ }
+}
diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set object alter migration__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set object alter migration__1.txt
new file mode 100644
index 0000000000..b44ae1a92f
--- /dev/null
+++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set object alter migration__1.txt
@@ -0,0 +1,25 @@
+primary()
+ ->datetime('created_at')
+ ->datetime('updated_at');
+ }
+
+ public function down(): QueryStatement
+ {
+ return new DropTableStatement('books');
+ }
+}
diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set request__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set request__1.txt
new file mode 100644
index 0000000000..910d9caa4c
--- /dev/null
+++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set request__1.txt
@@ -0,0 +1,17 @@
+setStatus(Status::OK);
+ $this->addHeader('Content-Type', 'application/json');
+ }
+}
diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set sessions table migration__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set sessions table migration__1.txt
new file mode 100644
index 0000000000..e884c71e04
--- /dev/null
+++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set sessions table migration__1.txt
@@ -0,0 +1,23 @@
+uuid('id')
+ ->datetime('created_at')
+ ->datetime('last_active_at')
+ ->text('data');
+ }
+}
diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set singleton initializer__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set singleton initializer__1.txt
new file mode 100644
index 0000000000..f9eeaf7fd1
--- /dev/null
+++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set singleton initializer__1.txt
@@ -0,0 +1,18 @@
+primary()
+ ->datetime('created_at')
+ ->datetime('updated_at');
+ }
+}
diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set user model__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set user model__1.txt
new file mode 100644
index 0000000000..db6089034c
--- /dev/null
+++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set user model__1.txt
@@ -0,0 +1,20 @@
+primary()
+ ->string('email')
+ ->string('password', nullable: true);
+ }
+}
diff --git a/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set view__1.txt b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set view__1.txt
new file mode 100644
index 0000000000..369d72beea
--- /dev/null
+++ b/tests/Integration/Generation/__snapshots__/StubFileGeneratorTest__generates_class_files_from_production_stubs with data set view__1.txt
@@ -0,0 +1,18 @@
+path = __DIR__ . '/generated-class.view.php';
+ }
+}
diff --git a/tests/Integration/Http/CookieSessionIdResolverTest.php b/tests/Integration/Http/CookieSessionIdResolverTest.php
index 4a0b4349a5..701dee703e 100644
--- a/tests/Integration/Http/CookieSessionIdResolverTest.php
+++ b/tests/Integration/Http/CookieSessionIdResolverTest.php
@@ -44,6 +44,19 @@ public function set_cookie_with_insecure_base_uri(): void
$this->assertFalse($cookie->secure);
}
+ #[Test]
+ public function issue_new_id_replaces_the_cookie(): void
+ {
+ $cookies = $this->container->get(CookieManager::class);
+ $resolver = $this->container->get(CookieSessionIdResolver::class);
+
+ $previousId = (string) $resolver->resolve();
+ $id = (string) $resolver->issueNewId();
+
+ $this->assertNotSame($previousId, $id);
+ $this->assertSame($id, $cookies->get('tempest_session_id')->value);
+ }
+
#[Test]
public function cookie_name(): void
{
diff --git a/tests/Integration/Http/DatabaseSessionTest.php b/tests/Integration/Http/DatabaseSessionTest.php
index f6315c2a45..926c44fa5c 100644
--- a/tests/Integration/Http/DatabaseSessionTest.php
+++ b/tests/Integration/Http/DatabaseSessionTest.php
@@ -19,6 +19,7 @@
use Tempest\Http\Session\SessionCreated;
use Tempest\Http\Session\SessionDeleted;
use Tempest\Http\Session\SessionId;
+use Tempest\Http\Session\SessionIdResolver;
use Tempest\Http\Session\SessionManager;
use Tempest\Support\Random;
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
@@ -46,6 +47,7 @@ protected function configure(): void
$this->container->singleton(SessionManager::class, fn () => new DatabaseSessionManager(
$this->container->get(Clock::class),
$this->container->get(SessionConfig::class),
+ $this->container->get(SessionIdResolver::class),
));
$this->database->reset(migrate: false);
@@ -163,6 +165,24 @@ public function delete_removes_session_from_database(): void
);
}
+ #[Test]
+ public function regenerate_replaces_the_session_record(): void
+ {
+ $this->eventBus->preventEventHandling();
+
+ $this->session->set('magic_type', 'offensive');
+ $this->manager->save($this->session);
+
+ $previousId = $this->session->id;
+
+ $this->manager->regenerate($this->session);
+
+ $this->assertNotSame((string) $previousId, (string) $this->session->id);
+ $this->assertSessionNotExistsInDatabase($previousId);
+ $this->assertSessionExistsInDatabase($this->session->id);
+ $this->assertSessionDataInDatabase($this->session->id, ['magic_type' => 'offensive']);
+ }
+
#[Test]
public function is_valid_checks_expiration(): void
{
diff --git a/tests/Integration/Http/FileSessionTest.php b/tests/Integration/Http/FileSessionTest.php
index 2e624e4f87..29bfdf8129 100644
--- a/tests/Integration/Http/FileSessionTest.php
+++ b/tests/Integration/Http/FileSessionTest.php
@@ -16,6 +16,7 @@
use Tempest\Http\Session\SessionCreated;
use Tempest\Http\Session\SessionDeleted;
use Tempest\Http\Session\SessionId;
+use Tempest\Http\Session\SessionIdResolver;
use Tempest\Http\Session\SessionManager;
use Tempest\Support\Filesystem;
use Tempest\Support\Path;
@@ -52,6 +53,7 @@ protected function configure(): void
$this->container->singleton(SessionManager::class, fn () => new FileSessionManager(
$this->container->get(Clock::class),
$this->container->get(FileSessionConfig::class),
+ $this->container->get(SessionIdResolver::class),
));
}
@@ -154,6 +156,26 @@ public function delete_removes_session_file(): void
);
}
+ #[Test]
+ public function regenerate_replaces_the_session_file(): void
+ {
+ $this->session->set('key', 'value');
+ $this->manager->save($this->session);
+
+ $previousPath = Path\normalize($this->path, 'sessions', (string) $this->session->id);
+ $previousId = (string) $this->session->id;
+
+ $this->manager->regenerate($this->session);
+
+ $this->assertNotSame($previousId, (string) $this->session->id);
+ $this->assertFileDoesNotExist($previousPath);
+
+ $path = Path\normalize($this->path, 'sessions', (string) $this->session->id);
+
+ $this->assertFileExists($path);
+ $this->assertSame('value', $this->session->get('key'));
+ }
+
#[Test]
public function is_valid_checks_expiration(): void
{
@@ -190,16 +212,16 @@ public function delete_expired_sessions_removes_old_files(): void
));
$active = $this->manager->getOrCreate(new SessionId('active'));
- $this->manager->save($active);
+ $this->saveAtClockTime($active, 'test_sessions');
$expired = $this->manager->getOrCreate(new SessionId('expired'));
- $this->manager->save($expired);
+ $this->saveAtClockTime($expired, 'test_sessions');
// we expire the $expired one
$clock->plus(Duration::minutes(35));
// keep active session fresh
- $this->manager->save($active);
+ $this->saveAtClockTime($active, 'test_sessions');
$activePath = Path\normalize($this->path, 'test_sessions', (string) $active->id);
$expiredPath = Path\normalize($this->path, 'test_sessions', (string) $expired->id);
@@ -220,4 +242,93 @@ public function delete_expired_sessions_removes_old_files(): void
count: 1,
);
}
+
+ #[Test]
+ public function delete_expired_sessions_removes_unreadable_files(): void
+ {
+ $this->eventBus->preventEventHandling();
+
+ $clock = $this->clock('2023-01-01 00:00:00');
+
+ $this->container->config(new FileSessionConfig(
+ expiration: Duration::minutes(30),
+ path: 'test_sessions',
+ ));
+
+ $corruptPath = Path\normalize($this->path, 'test_sessions', 'corrupt');
+ Filesystem\write_file($corruptPath, 'not a serialized session');
+ touch($corruptPath, $clock->now()->getTimestamp()->getSeconds());
+
+ $clock->plus(Duration::minutes(35));
+
+ $this->manager->deleteExpiredSessions();
+
+ $this->assertFileDoesNotExist($corruptPath);
+
+ $this->eventBus->assertDispatched(
+ event: SessionDeleted::class,
+ callback: function (SessionDeleted $event): void {
+ $this->assertEquals(new SessionId('corrupt'), $event->id);
+ },
+ count: 1,
+ );
+ }
+
+ #[Test]
+ public function unchanged_session_is_not_rewritten(): void
+ {
+ $clock = $this->clock('2025-01-01 00:00:00');
+ $id = new SessionId('unchanged');
+ $path = Path\normalize($this->path, 'sessions', (string) $id);
+
+ $session = $this->manager->getOrCreate($id);
+ $session->set('user', 'brent');
+
+ $this->manager->save($session);
+
+ $unchanged = $this->manager->getOrCreate($id);
+
+ // Another request writes to the same session in the meantime
+ $concurrent = $this->manager->getOrCreate($id);
+ $concurrent->set('cart', 'book');
+
+ $this->manager->save($concurrent);
+
+ $clock->plus(Duration::minutes(5));
+ $this->manager->save($unchanged);
+
+ clearstatcache();
+
+ $this->assertSame('book', $this->manager->getOrCreate($id)->get('cart'));
+ $this->assertSame($clock->now()->getTimestamp()->getSeconds(), filemtime($path));
+ }
+
+ #[Test]
+ public function last_active_at_is_read_from_modification_time(): void
+ {
+ $clock = $this->clock('2025-01-01 00:00:00');
+ $id = new SessionId('active');
+
+ $session = $this->manager->getOrCreate($id);
+ $session->set('user', 'brent');
+
+ $this->manager->save($session);
+
+ $clock->plus(Duration::minutes(5));
+ $this->manager->save($this->manager->getOrCreate($id));
+
+ clearstatcache();
+
+ $this->assertTrue($this->manager->getOrCreate($id)->lastActiveAt->equals($clock->now()));
+ }
+
+ private function saveAtClockTime(Session $session, string $path): void
+ {
+ $this->manager->save($session);
+
+ touch(
+ Path\normalize($this->path, $path, (string) $session->id),
+ $session->lastActiveAt->getTimestamp()->getSeconds(),
+ );
+ }
}
diff --git a/tests/Integration/Http/ManageSessionMiddlewareTest.php b/tests/Integration/Http/ManageSessionMiddlewareTest.php
new file mode 100644
index 0000000000..af4c9197a4
--- /dev/null
+++ b/tests/Integration/Http/ManageSessionMiddlewareTest.php
@@ -0,0 +1,187 @@
+path);
+
+ $this->path = realpath($this->path);
+ $this->container->get(FrameworkKernel::class)->internalStorage = $this->path;
+
+ $this->config = new FileSessionConfig(
+ expiration: Duration::hours(2),
+ cleanupStrategy: CleanupStrategy::DISABLED,
+ path: 'sessions',
+ );
+
+ $this->manager = new FileSessionManager(
+ clock: $this->container->get(Clock::class),
+ sessionConfig: $this->config,
+ sessionIdResolver: $this->container->get(SessionIdResolver::class),
+ );
+ }
+
+ #[PostCondition]
+ protected function cleanup(): void
+ {
+ Filesystem\delete_directory($this->path);
+ }
+
+ #[Test]
+ public function new_session_without_data_is_not_persisted(): void
+ {
+ $session = $this->manager->getOrCreate(new SessionId('new-session'));
+
+ $this->handle($session);
+
+ $this->assertFileDoesNotExist($this->sessionPath($session));
+ }
+
+ #[Test]
+ public function new_session_with_only_previous_url_is_not_persisted_for_non_browser_clients(): void
+ {
+ $session = $this->manager->getOrCreate(new SessionId('new-session'));
+
+ $this->handle($session, fn (Session $session) => $session->set(PreviousUrl::PREVIOUS_URL_SESSION_KEY, '/dashboard'));
+
+ $this->assertFileDoesNotExist($this->sessionPath($session));
+ }
+
+ #[Test]
+ public function new_session_with_only_previous_url_is_persisted_for_browser_navigation(): void
+ {
+ $session = $this->manager->getOrCreate(new SessionId('new-session'));
+
+ // The referer of this request doesn't tell whether the request redirecting back will send one
+ $this->handle(
+ session: $session,
+ controller: fn (Session $session) => $session->set(PreviousUrl::PREVIOUS_URL_SESSION_KEY, '/contact'),
+ headers: ['sec-fetch-mode' => 'navigate', 'referer' => 'https://search.example/'],
+ );
+
+ $this->assertFileExists($this->sessionPath($session));
+ }
+
+ #[Test]
+ public function new_session_with_only_previous_url_is_not_persisted_for_browser_subrequests(): void
+ {
+ $session = $this->manager->getOrCreate(new SessionId('new-session'));
+
+ $this->handle(
+ session: $session,
+ controller: fn (Session $session) => $session->set(PreviousUrl::PREVIOUS_URL_SESSION_KEY, '/api/stats'),
+ headers: ['sec-fetch-mode' => 'cors'],
+ );
+
+ $this->assertFileDoesNotExist($this->sessionPath($session));
+ }
+
+ #[Test]
+ public function new_session_with_data_is_persisted(): void
+ {
+ $session = $this->manager->getOrCreate(new SessionId('new-session'));
+
+ $this->handle($session, fn (Session $session) => $session->set('user', 'brent'));
+
+ $this->assertFileExists($this->sessionPath($session));
+ $this->assertFalse($session->isNew);
+ }
+
+ #[Test]
+ public function new_session_with_flash_value_is_persisted(): void
+ {
+ $session = $this->manager->getOrCreate(new SessionId('new-session'));
+
+ $this->handle($session, fn (Session $session) => $session->flash('success', 'Saved!'));
+
+ $this->assertFileExists($this->sessionPath($session));
+ }
+
+ #[Test]
+ public function existing_session_is_persisted_after_being_cleared(): void
+ {
+ $session = $this->manager->getOrCreate(new SessionId('existing-session'));
+
+ $this->handle($session, fn (Session $session) => $session->set('user', 'brent'));
+ $this->handle($session, fn (Session $session) => $session->clear());
+
+ $this->assertSame([], $this->manager->getOrCreate(new SessionId('existing-session'))->all());
+ }
+
+ #[Test]
+ public function loaded_session_is_not_new(): void
+ {
+ $session = $this->manager->getOrCreate(new SessionId('existing-session'));
+ $this->assertTrue($session->isNew);
+
+ $this->handle($session, fn (Session $session) => $session->set('user', 'brent'));
+
+ $this->assertFalse($this->manager->getOrCreate(new SessionId('existing-session'))->isNew);
+ }
+
+ private function handle(Session $session, ?Closure $controller = null, array $headers = []): void
+ {
+ $middleware = new ManageSessionMiddleware(
+ sessionManager: $this->manager,
+ session: $session,
+ config: $this->config,
+ deferredTasks: $this->container->get(DeferredTasks::class),
+ clock: $this->container->get(Clock::class),
+ );
+
+ $middleware(
+ request: new GenericRequest(method: Method::GET, uri: '/', headers: $headers),
+ next: new HttpMiddlewareCallable(function () use ($session, $controller) {
+ if ($controller instanceof Closure) {
+ $controller($session);
+ }
+
+ return new GenericResponse(Status::OK);
+ }),
+ );
+ }
+
+ private function sessionPath(Session $session): string
+ {
+ return $this->path . '/sessions/' . $session->id;
+ }
+}
diff --git a/tests/Integration/Http/RedisSessionTest.php b/tests/Integration/Http/RedisSessionTest.php
index ac19c832e0..e103ac9a71 100644
--- a/tests/Integration/Http/RedisSessionTest.php
+++ b/tests/Integration/Http/RedisSessionTest.php
@@ -16,6 +16,7 @@
use Tempest\Http\Session\SessionCreated;
use Tempest\Http\Session\SessionDeleted;
use Tempest\Http\Session\SessionId;
+use Tempest\Http\Session\SessionIdResolver;
use Tempest\Http\Session\SessionManager;
use Tempest\KeyValue\Redis\Redis;
use Tempest\Support\Random;
@@ -47,6 +48,7 @@ protected function configure(): void
clock: $this->container->get(Clock::class),
redis: $this->container->get(Redis::class),
config: $this->container->get(RedisSessionConfig::class),
+ sessionIdResolver: $this->container->get(SessionIdResolver::class),
));
try {
@@ -173,6 +175,24 @@ public function delete_removes_session_from_redis(): void
);
}
+ #[Test]
+ public function regenerate_replaces_the_session_key(): void
+ {
+ $this->eventBus->preventEventHandling();
+
+ $this->session->set('magic_type', 'offensive');
+ $this->manager->save($this->session);
+
+ $previousId = $this->session->id;
+
+ $this->manager->regenerate($this->session);
+
+ $this->assertNotSame((string) $previousId, (string) $this->session->id);
+ $this->assertSessionNotExistsInRedis($previousId);
+ $this->assertSessionExistsInRedis($this->session->id);
+ $this->assertSessionDataInRedis($this->session->id, ['magic_type' => 'offensive']);
+ }
+
#[Test]
public function is_valid_checks_expiration(): void
{
diff --git a/tests/Integration/Http/SessionCleanupStrategyTest.php b/tests/Integration/Http/SessionCleanupStrategyTest.php
index 444ac468f2..4f6cce0e09 100644
--- a/tests/Integration/Http/SessionCleanupStrategyTest.php
+++ b/tests/Integration/Http/SessionCleanupStrategyTest.php
@@ -20,8 +20,11 @@
use Tempest\Http\Session\SessionManager;
use Tempest\Http\Status;
use Tempest\Router\HttpMiddlewareCallable;
+use Tempest\Support\Filesystem;
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
+use function Tempest\internal_storage_path;
+
/**
* @internal
*/
@@ -31,6 +34,8 @@ final class SessionCleanupStrategyTest extends FrameworkIntegrationTestCase
protected function cleanup(): void
{
$this->clearDeferredTasks();
+
+ Filesystem\delete(internal_storage_path('session-cleanup'));
}
#[Test]
@@ -63,6 +68,66 @@ public function disabled_does_not_schedule_cleanup_task(): void
);
}
+ #[Test]
+ public function random_requests_schedules_cleanup_at_most_once_per_interval(): void
+ {
+ $clock = $this->clock('2025-01-01 00:00:00');
+
+ $config = new FileSessionConfig(
+ expiration: Duration::minutes(30),
+ cleanupStrategy: CleanupStrategy::RANDOM_REQUESTS,
+ path: 'tests/sessions',
+ );
+
+ // The odds of a cleanup are 1 in 50 per request, so 1000 requests are virtually guaranteed to trigger one.
+ $this->assertTrue($this->cleanupScheduledWithin(1000, $config));
+ $this->assertFalse($this->cleanupScheduledWithin(1000, $config));
+
+ $clock->plus(Duration::seconds(30));
+ $this->assertFalse($this->cleanupScheduledWithin(1000, $config));
+
+ $clock->plus(Duration::seconds(31));
+ $this->assertTrue($this->cleanupScheduledWithin(1000, $config));
+ }
+
+ #[Test]
+ public function random_requests_does_not_schedule_cleanup_while_another_request_claims_it(): void
+ {
+ $this->clock('2025-01-01 00:00:00');
+
+ $config = new FileSessionConfig(
+ expiration: Duration::minutes(30),
+ cleanupStrategy: CleanupStrategy::RANDOM_REQUESTS,
+ path: 'tests/sessions',
+ );
+
+ // Simulates a concurrent request that is in the middle of claiming the cleanup
+ $handle = fopen(internal_storage_path('session-cleanup'), mode: 'c+');
+ flock($handle, LOCK_EX);
+
+ try {
+ $this->assertFalse($this->cleanupScheduledWithin(1000, $config));
+ } finally {
+ flock($handle, LOCK_UN);
+ fclose($handle);
+ }
+
+ $this->assertTrue($this->cleanupScheduledWithin(1000, $config));
+ }
+
+ private function cleanupScheduledWithin(int $requests, FileSessionConfig $config): bool
+ {
+ for ($i = 0; $i < $requests; $i++) {
+ $this->invokeMiddlewareWith($config);
+
+ if (array_key_exists('tempest:session-cleanup', $this->container->get(DeferredTasks::class)->getTasks())) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
private function invokeMiddlewareWith(FileSessionConfig $config): void
{
$this->clearDeferredTasks();
@@ -83,6 +148,7 @@ private function invokeMiddlewareWith(FileSessionConfig $config): void
session: $session,
config: $config,
deferredTasks: $this->container->get(DeferredTasks::class),
+ clock: $clock,
);
$response = $middleware(
@@ -130,6 +196,8 @@ public function save(Session $session): void
public function delete(Session $session): void {}
+ public function regenerate(Session $session): void {}
+
public function isValid(Session $session): bool
{
return true;
diff --git a/tests/Integration/Http/SessionTest.php b/tests/Integration/Http/SessionTest.php
index e6d22a9107..48ac8bbc38 100644
--- a/tests/Integration/Http/SessionTest.php
+++ b/tests/Integration/Http/SessionTest.php
@@ -4,8 +4,11 @@
namespace Tests\Tempest\Integration\Http;
+use ArrayObject;
use PHPUnit\Framework\Attributes\Test;
+use Tempest\DateTime\DateTime;
use Tempest\Http\Session\Session;
+use Tempest\Http\Session\SessionId;
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
/**
@@ -139,6 +142,17 @@ public function clear(): void
$this->assertEmpty($this->session->all());
}
+ #[Test]
+ public function replace_id_preserves_data(): void
+ {
+ $this->session->set('key', 'value');
+
+ $this->session->replaceId(new SessionId('new_session'));
+
+ $this->assertSame('new_session', (string) $this->session->id);
+ $this->assertSame('value', $this->session->get('key'));
+ }
+
#[Test]
public function session_is_reset(): void
{
@@ -150,4 +164,66 @@ public function session_is_reset(): void
$this->assertNotSame($originalSession, $newSession);
}
+
+ #[Test]
+ public function is_dirty_after_changes(): void
+ {
+ $session = $this->createSession(['user' => 'brent']);
+ $this->assertFalse($session->isDirty());
+
+ $session->set('user', 'brent');
+ $this->assertFalse($session->isDirty());
+
+ $session->set('user', 'aidan');
+ $this->assertTrue($session->isDirty());
+
+ $session->markAsPersisted();
+ $this->assertFalse($session->isDirty());
+
+ $session->remove('user');
+ $this->assertTrue($session->isDirty());
+ }
+
+ #[Test]
+ public function is_dirty_after_values_are_modified_in_place(): void
+ {
+ $session = $this->createSession(['cart' => new ArrayObject()]);
+
+ $session->get('cart')->append('book');
+
+ $this->assertTrue($session->isDirty());
+ }
+
+ #[Test]
+ public function is_dirty_after_flash_values_are_aged(): void
+ {
+ $session = $this->createSession();
+ $session->flash('success', 'Saved!');
+ $session->markAsPersisted();
+
+ $session->cleanup();
+
+ $this->assertTrue($session->isDirty());
+ }
+
+ #[Test]
+ public function is_not_dirty_after_being_unserialized(): void
+ {
+ $session = $this->createSession();
+ $session->set('user', 'brent');
+
+ $this->assertFalse(unserialize(serialize($session))->isDirty());
+ }
+
+ private function createSession(array $data = []): Session
+ {
+ $now = DateTime::now();
+
+ return new Session(
+ id: new SessionId('dirty-test'),
+ createdAt: $now,
+ lastActiveAt: $now,
+ data: $data,
+ );
+ }
}
diff --git a/tests/Integration/Mapper/Fixtures/ObjectWithPromotedDefaults.php b/tests/Integration/Mapper/Fixtures/ObjectWithPromotedDefaults.php
new file mode 100644
index 0000000000..5cd20e3b05
--- /dev/null
+++ b/tests/Integration/Mapper/Fixtures/ObjectWithPromotedDefaults.php
@@ -0,0 +1,17 @@
+assertNull($object->b);
}
+ #[Test]
+ public function map_promoted_defaults_without_running_constructor(): void
+ {
+ ObjectWithPromotedDefaults::$constructorCalls = 0;
+
+ $object = map(['name' => 'given'])->to(ObjectWithPromotedDefaults::class);
+
+ $this->assertSame('given', $object->name);
+ $this->assertSame(5, $object->count);
+ $this->assertSame(0, ObjectWithPromotedDefaults::$constructorCalls);
+ }
+
#[Test]
public function built_in_casters(): void
{
diff --git a/tests/Integration/Route/ClientTest.php b/tests/Integration/Route/ClientTest.php
index 57a74fe97c..467d6a7ccc 100644
--- a/tests/Integration/Route/ClientTest.php
+++ b/tests/Integration/Route/ClientTest.php
@@ -28,7 +28,12 @@ protected function setUp(): void
{
parent::setUp();
- $this->server = new Process([root_path('tempest'), 'serve', 'localhost', '8088']);
+ // The server is started directly instead of through `tempest serve`, which runs it in a child process
+ // that would keep running after this process is stopped.
+ $this->server = new Process(
+ command: [PHP_BINARY, '-S', 'localhost:8088', '-t', 'public/', root_path('packages/router/src/Commands/router.php')],
+ cwd: root_path(),
+ );
$this->server->start();
// Server needs to start
diff --git a/tests/Integration/Route/RouterTest.php b/tests/Integration/Route/RouterTest.php
index 1b06897d6b..b296de0efd 100644
--- a/tests/Integration/Route/RouterTest.php
+++ b/tests/Integration/Route/RouterTest.php
@@ -480,6 +480,8 @@ public function save(Session $session): void
public function delete(Session $session): void {}
+ public function regenerate(Session $session): void {}
+
public function isValid(Session $session): bool
{
return true;
diff --git a/tests/Integration/View/Components/MarkdownComponentTest.php b/tests/Integration/View/Components/MarkdownComponentTest.php
index 67cf1c6981..6a59c28e12 100644
--- a/tests/Integration/View/Components/MarkdownComponentTest.php
+++ b/tests/Integration/View/Components/MarkdownComponentTest.php
@@ -14,7 +14,7 @@ public function render_markdown_as_content(): void
# hi
HTML);
- $this->assertSame('hi
', $html);
+ $this->assertSame('hi
', $html);
}
#[Test]
@@ -24,6 +24,6 @@ public function render_markdown_as_variable(): void
HTML, text: '# hi');
- $this->assertSame('hi
', $html);
+ $this->assertSame('hi
', $html);
}
}