From 050f60006ebab39a266d41d99f4113fc53f6c284 Mon Sep 17 00:00:00 2001 From: Elliot Anderson Date: Thu, 11 Dec 2025 12:20:24 -0500 Subject: [PATCH 1/5] generate csrf token if null --- src/Http/Middleware/CsrfProtection.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Http/Middleware/CsrfProtection.php b/src/Http/Middleware/CsrfProtection.php index 67e05e5..86a7629 100644 --- a/src/Http/Middleware/CsrfProtection.php +++ b/src/Http/Middleware/CsrfProtection.php @@ -53,7 +53,10 @@ protected function ensureToken(): void $token = Session::get('_token'); - // Expose token in a readable cookie for SPAs (React/Vue/etc) + if ($token === null) { + $token = bin2hex(random_bytes(32)); + Session::set('_token', $token); + } // NON HttpOnly // SameSite=Lax From b151fd63f7adb2a3f843df3b0a8612a25adf60b5 Mon Sep 17 00:00:00 2001 From: Elliot Anderson Date: Thu, 11 Dec 2025 12:26:36 -0500 Subject: [PATCH 2/5] add fillable/guarded attributes to model --- src/Database/Model.php | 53 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/Database/Model.php b/src/Database/Model.php index 63ed190..0e3e1c0 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -32,6 +32,20 @@ abstract class Model implements ArrayAccess protected static string $table; + /** + * Mass assignable attributes. + * + * @var string[] $fillable + */ + protected array $fillable = []; + + /** + * The attributes that aren't mass-assignable. + * By default, everything is guarded until explicitly allowed via $fillable. + * + * @var string[] $guarded + */ + protected array $guarded = ['*']; protected bool $timestamps = true; protected array $attributes = []; @@ -69,6 +83,45 @@ public function __construct(array $attributes = []) } } + /** + * Fill the model with an array of attributes, applying mass-assignment rules. + * + * @param array $attributes + * @return $this + */ + public function fill(array $attributes): static + { + foreach ($attributes as $key => $value) { + if (! $this->isFillable($key)) { + continue; + } + + $this->setAttribute($key, $value); + } + + return $this; + } + + /** + * Determine if the given key is mass assignable. + * + * @param string $key + * @return bool + */ + protected function isFillable(string $key): bool + { + // Explicitly fillable + if (in_array($key, $this->fillable, true)) { + return true; + } + + if (in_array('*', $this->guarded, true)) { + return false; + } + + return ! in_array($key, $this->guarded, true); + } + // called once during bootstrap public static function setConnection(Connection $connection): void { From 46e6b4ca3e39f17c21afe8bf4e070bf2964ef0d9 Mon Sep 17 00:00:00 2001 From: Elliot Anderson Date: Thu, 11 Dec 2025 13:11:49 -0500 Subject: [PATCH 3/5] experimental: datamapper thin DataMapper wrapper: EXPERIMENTAL. --- phpunit.out | 675 ++++++++++++++++++++++ resources/js/App.jsx | 12 - src/Database/Builder.php | 67 +++ src/Database/Entity.php | 126 ++++ src/Database/EntityManager.php | 183 ++++++ src/Database/EntityQuery.php | 81 +++ src/Database/Model.php | 11 - src/Providers/DatabaseServiceProvider.php | 8 + vite.config.js | 21 - 9 files changed, 1140 insertions(+), 44 deletions(-) delete mode 100644 resources/js/App.jsx create mode 100644 src/Database/Entity.php create mode 100644 src/Database/EntityManager.php create mode 100644 src/Database/EntityQuery.php delete mode 100644 vite.config.js diff --git a/phpunit.out b/phpunit.out index 233294a..ea8276b 100644 --- a/phpunit.out +++ b/phpunit.out @@ -1299,3 +1299,678 @@ Test code or tested code printed unexpected output: No migration files found. ERRORS! Tests: 510, Assertions: 921, Errors: 63, Failures: 8, PHPUnit Notices: 10, Skipped: 4, Risky: 14. +PHPUnit 12.5.3 by Sebastian Bergmann and contributors. + +Runtime: PHP 8.4.15 +Configuration: /Users/elliotanderson/php/baremetalphp-src/phpunit.xml + +...FFFF...EEEEEEEEEEE...EEEE........EEEEEEEEEEEEEEEE........... 63 / 510 ( 12%) +.....................................................SSS.....S. 126 / 510 ( 24%) +............................................................... 189 / 510 ( 37%) +................................................R.............. 252 / 510 ( 49%) +..........EEEEEEEEEEEEEEEEEEEF.EF..Migration created: /private/var/folders/bk/l00_jcf56y92rzhh262xnxn00000gn/T/baremetal_migration_test_693b035680851/database/migrations/20251211174558_test_migration.php +RMigration created: /private/var/folders/bk/l00_jcf56y92rzhh262xnxn00000gn/T/baremetal_migration_test_693b035680b4e/database/migrations/20251211174558_test_migration.php +R.Controller created: app/Http/Controllers/TestController.php +R.......FFF.............. 315 / 510 ( 61%) +..........N.................................................... 378 / 510 ( 74%) +............................................................... 441 / 510 ( 86%) +WebSocket broadcast: failed to encode payload +R..........................................Installing Go app server into: /private/var/folders/bk/l00_jcf56y92rzhh262xnxn00000gn/T/baremetal_test_693b035690358 +Created go.mod with module name: baremetal_test_693b035690358 +Created go_appserver.json +Created cmd/server/main.go +Created cmd/server/config.go +Created server/server.go +Created server/worker.go +Created server/pool.go +Created server/payload.go +Created php/worker.php +Created php/bridge.php +Created php/bootstrap_app.php +Go app server scaffolding complete. +Next steps: + 1. Run: go mod tidy + 2. Run: go run ./cmd/server + 3. Hit http://localhost:8080 +R.Installing Go app server into: /private/var/folders/bk/l00_jcf56y92rzhh262xnxn00000gn/T/baremetal_test_693b035691f79 +Created go.mod with module name: baremetal_test_693b035691f79 +Created go_appserver.json +Created cmd/server/main.go +Created cmd/server/config.go +Created server/server.go +Created server/worker.go +Created server/pool.go +Created server/payload.go +Created php/worker.php +Created php/bridge.php +Created php/bootstrap_app.php +Go app server scaffolding complete. +Next steps: + 1. Run: go mod tidy + 2. Run: go run ./cmd/server + 3. Hit http://localhost:8080 +RInstalling Go app server into: /private/var/folders/bk/l00_jcf56y92rzhh262xnxn00000gn/T/baremetal_test_693b03569294c +Created go.mod with module name: baremetal_test_693b03569294c +Created go_appserver.json +Created cmd/server/main.go +Created cmd/server/config.go +Created server/server.go +Created server/worker.go +Created server/pool.go +Created server/payload.go +Created php/worker.php +Created php/bridge.php +Created php/bootstrap_app.php +Go app server scaffolding complete. +Next steps: + 1. Run: go mod tidy + 2. Run: go run ./cmd/server + 3. Hit http://localhost:8080 +RInstalling Go app server into: /private/var/folders/bk/l00_jcf56y92rzhh262xnxn00000gn/T/baremetal_test_693b035693355 +Created go.mod with module name: baremetal_test_693b035693355 +Created go_appserver.json +Created cmd/server/main.go +Created cmd/server/config.go +Created server/server.go +Created server/worker.go +Created server/pool.go +Created server/payload.go +Created php/worker.php +Created php/bridge.php +Created php/bootstrap_app.php +Go app server scaffolding complete. +Next steps: + 1. Run: go mod tidy + 2. Run: go run ./cmd/server + 3. Hit http://localhost:8080 +R.NNNNNo migration files found. +RNNNN..... 504 / 510 ( 98%) +....E. 510 / 510 (100%) + +Time: 00:00.944, Memory: 22.00 MB + +There were 52 errors: + +1) Tests\Unit\Database\BelongsToManyTest::testGetReturnsCollection +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/Database/BelongsToManyTest.php:48 + +2) Tests\Unit\Database\BelongsToManyTest::testAttachSingleId +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/Database/BelongsToManyTest.php:92 + +3) Tests\Unit\Database\BelongsToManyTest::testAttachMultipleIds +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/Database/BelongsToManyTest.php:115 + +4) Tests\Unit\Database\BelongsToManyTest::testAttachWithPivotAttributes +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/Database/BelongsToManyTest.php:139 + +5) Tests\Unit\Database\BelongsToManyTest::testAttachUpdatesExistingPivotAttributes +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/Database/BelongsToManyTest.php:162 + +6) Tests\Unit\Database\BelongsToManyTest::testDetachSingleId +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/Database/BelongsToManyTest.php:189 + +7) Tests\Unit\Database\BelongsToManyTest::testDetachAll +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/Database/BelongsToManyTest.php:216 + +8) Tests\Unit\Database\BelongsToManyTest::testSync +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/Database/BelongsToManyTest.php:261 + +9) Tests\Unit\Database\BelongsToManyTest::testSyncWithoutDetaching +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/Database/BelongsToManyTest.php:306 + +10) Tests\Unit\Database\BelongsToManyTest::testGetPivotAttributes +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/Database/BelongsToManyTest.php:335 + +11) Tests\Unit\Database\BelongsToManyTest::testGetPivotAttributesReturnsEmptyWhenNotFound +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/Database/BelongsToManyTest.php:362 + +12) Tests\Unit\Database\BuilderAdvancedTest::testWithEagerLoading +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/Database/BuilderAdvancedTest.php:81 + +13) Tests\Unit\Database\BuilderAdvancedTest::testWithArraySyntax +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/Database/BuilderAdvancedTest.php:121 + +14) Tests\Unit\Database\BuilderAdvancedTest::testWithMultipleRelations +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/Database/BuilderAdvancedTest.php:100 + +15) Tests\Unit\Database\BuilderAdvancedTest::testFirstWithModel +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/Database/BuilderAdvancedTest.php:187 + +16) Tests\Unit\DatabaseRelationsTest::testHasManyGetResultsReturnsCollection +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/DatabaseRelationsTest.php:97 + +17) Tests\Unit\DatabaseRelationsTest::testHasManyAddEagerConstraints +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/DatabaseRelationsTest.php:122 + +18) Tests\Unit\DatabaseRelationsTest::testHasManyGetEager +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/DatabaseRelationsTest.php:135 + +19) Tests\Unit\DatabaseRelationsTest::testHasManyGetEagerWithEmptyKeys +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/DatabaseRelationsTest.php:152 + +20) Tests\Unit\DatabaseRelationsTest::testBelongsToAddEagerConstraints +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/DatabaseRelationsTest.php:209 + +21) Tests\Unit\DatabaseRelationsTest::testBelongsToGetEager +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/DatabaseRelationsTest.php:222 + +22) Tests\Unit\DatabaseRelationsTest::testBelongsToGetEagerWithEmptyKeys +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/DatabaseRelationsTest.php:238 + +23) Tests\Unit\DatabaseRelationsTest::testBelongsToMatch +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/DatabaseRelationsTest.php:249 + +24) Tests\Unit\DatabaseRelationsTest::testHasOneGetResultsReturnsModel +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/DatabaseRelationsTest.php:266 + +25) Tests\Unit\DatabaseRelationsTest::testHasOneAddEagerConstraints +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/DatabaseRelationsTest.php:289 + +26) Tests\Unit\DatabaseRelationsTest::testHasOneGetEager +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/DatabaseRelationsTest.php:302 + +27) Tests\Unit\DatabaseRelationsTest::testHasManyMatch +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/DatabaseRelationsTest.php:163 + +28) Tests\Unit\DatabaseRelationsTest::testBelongsToGetResultsReturnsModel +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/DatabaseRelationsTest.php:186 + +29) Tests\Unit\DatabaseRelationsTest::testHasOneGetEagerWithEmptyKeys +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/DatabaseRelationsTest.php:318 + +30) Tests\Unit\DatabaseRelationsTest::testHasOneMatch +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/DatabaseRelationsTest.php:329 + +31) Tests\Unit\DatabaseRelationsTest::testHasOneMatchWithMissingProfile +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/DatabaseRelationsTest.php:346 + +32) Tests\Unit\ModelRelationshipsTest::test_has_many_returns_collection_of_related_models +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelRelationshipsTest.php:50 + +33) Tests\Unit\ModelRelationshipsTest::test_has_many_returns_empty_collection_when_no_related_rows +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelRelationshipsTest.php:81 + +34) Tests\Unit\ModelRelationshipsTest::test_has_one_returns_single_related_model_via_method_and_property +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelRelationshipsTest.php:94 + +35) Tests\Unit\ModelRelationshipsTest::test_has_one_method_returns_null_when_no_related_model +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelRelationshipsTest.php:117 + +36) Tests\Unit\ModelRelationshipsTest::test_belongs_to_returns_parent_model +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelRelationshipsTest.php:128 + +37) Tests\Unit\ModelRelationshipsTest::test_belongs_to_method_returns_null_when_foreign_key_is_null +PDOException: SQLSTATE[HY000]: General error: 1 near ")": syntax error + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:603 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelRelationshipsTest.php:158 + +38) Tests\Unit\ModelTest::testCanSaveModel +PDOException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.name + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:604 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelTest.php:44 + +39) Tests\Unit\ModelTest::testCanFindModel +PDOException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.name + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:604 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelTest.php:54 + +40) Tests\Unit\ModelTest::testCanUpdateModel +PDOException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.name + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:604 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelTest.php:65 + +41) Tests\Unit\ModelTest::testCanDeleteModel +PDOException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.name + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:604 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelTest.php:76 + +42) Tests\Unit\ModelTest::testCanUseStaticCreate +PDOException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.name + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:604 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelTest.php:87 + +43) Tests\Unit\ModelTest::testCanUseFirstOrCreate +PDOException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.name + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:604 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:414 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelTest.php:99 + +44) Tests\Unit\ModelTest::testCanUseUpdateOrCreate +PDOException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.name + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:604 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:414 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:422 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelTest.php:117 + +45) Tests\Unit\ModelTest::testCanUseFindOrFail +PDOException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.name + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:604 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelTest.php:134 + +46) Tests\Unit\ModelTest::testCanRefreshModel +PDOException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.name + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:604 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelTest.php:154 + +47) Tests\Unit\ModelTest::testCanGetFreshInstance +PDOException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.name + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:604 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelTest.php:164 + +48) Tests\Unit\ModelTest::testCanQueryModels +PDOException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.name + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:604 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelTest.php:175 + +49) Tests\Unit\ModelTest::testCanGetAllModels +PDOException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.name + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:604 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelTest.php:186 + +50) Tests\Unit\ModelTest::testCanUseAccessors +PDOException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.name + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:604 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelTest.php:197 + +51) Tests\Unit\ModelTest::testCanConvertToArray +PDOException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.name + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:604 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelTest.php:214 + +52) Tests\Integration\FullStackTest::testFullRequestResponseCycle +PDOException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.name + +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:604 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:457 +/Users/elliotanderson/php/baremetalphp-src/src/Database/Model.php:385 +/Users/elliotanderson/php/baremetalphp-src/tests/Integration/FullStackTest.php:51 +/Users/elliotanderson/php/baremetalphp-src/vendor/bin/phpunit:122 + +-- + +There were 9 failures: + +1) Tests\Unit\AuthTest::testLoginSetsUserIdInSession +Failed asserting that null is identical to 1. + +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/AuthTest.php:65 + +2) Tests\Unit\AuthTest::testAttemptLogsInUserWithValidCredentials +Failed asserting that null is not null. + +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/AuthTest.php:124 + +3) Tests\Unit\AuthTest::testAttemptTrimsEmailWhitespace +Failed asserting that null is not null. + +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/AuthTest.php:152 + +4) Tests\Unit\AuthTest::testAttemptIsCaseInsensitiveForEmail +Failed asserting that null is not null. + +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/AuthTest.php:139 + +5) Tests\Unit\ModelTest::testCanFillAttributes +Failed asserting that null matches expected 'John'. + +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelTest.php:148 + +6) Tests\Unit\ModelTest::testCanCreateModel +Failed asserting that null matches expected 'John'. + +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/ModelTest.php:37 + +7) Tests\Unit\MorphRelationshipsTest::testMorphManyUsesMorphClass +Failed asserting that null is identical to 'Post comment'. + +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/MorphRelationshipsTest.php:203 + +8) Tests\Unit\MorphRelationshipsTest::testMorphToReturnsRelatedModel +Failed asserting that null is identical to 1. + +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/MorphRelationshipsTest.php:102 + +9) Tests\Unit\MorphRelationshipsTest::testMorphOneReturnsSingleRelatedModel +Failed asserting that null is identical to 1. + +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/MorphRelationshipsTest.php:159 + +-- + +There were 10 risky tests: + +1) Tests\Unit\Frontend\SPAHelperTest::testRenderWithCustomLayout +This test did not perform any assertions + +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/Frontend/SPAHelperTest.php:89 + +2) Tests\Unit\MakeMigrationCommandTest::testMigrationHasTimestamp +Test code or tested code printed unexpected output: Migration created: /private/var/folders/bk/l00_jcf56y92rzhh262xnxn00000gn/T/baremetal_migration_test_693b035680851/database/migrations/20251211174558_test_migration.php + +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/MakeMigrationCommandTest.php:89 + +3) Tests\Unit\MakeMigrationCommandTest::testCreatesMigrationsDirectory +Test code or tested code printed unexpected output: Migration created: /private/var/folders/bk/l00_jcf56y92rzhh262xnxn00000gn/T/baremetal_migration_test_693b035680b4e/database/migrations/20251211174558_test_migration.php + +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/MakeMigrationCommandTest.php:102 + +4) Tests\Unit\MakeControllerCommandTest::testCreatesDirectoryStructure +Test code or tested code printed unexpected output: Controller created: app/Http/Controllers/TestController.php + +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/MakeControllerCommandTest.php:79 + +5) Tests\Unit\WebSocket\WebSocketTest::testBroadcastHandlesJsonEncodeFailure +Test code or tested code printed unexpected output: WebSocket broadcast: failed to encode payload + +/Users/elliotanderson/php/baremetalphp-src/tests/Unit/WebSocket/WebSocketTest.php:77 + +6) Tests\Feature\InstallGoAppServerCommandTest::testGoConfigFileReflectsAppServerConfig +Test code or tested code printed unexpected output: Installing Go app server into: /private/var/folders/bk/l00_jcf56y92rzhh262xnxn00000gn/T/baremetal_test_693b035690358 +Created go.mod with module name: baremetal_test_693b035690358 +Created go_appserver.json +Created cmd/server/main.go +Created cmd/server/config.go +Created server/server.go +Created server/worker.go +Created server/pool.go +Created server/payload.go +Created php/worker.php +Created php/bridge.php +Created php/bootstrap_app.php +Go app server scaffolding complete. +Next steps: + 1. Run: go mod tidy + 2. Run: go run ./cmd/server + 3. Hit http://localhost:8080 + +/Users/elliotanderson/php/baremetalphp-src/tests/Feature/InstallGoAppServerCommandTest.php:260 + +7) Tests\Feature\InstallGoAppServerCommandTest::testGoFilesContainCorrectContent +Test code or tested code printed unexpected output: Installing Go app server into: /private/var/folders/bk/l00_jcf56y92rzhh262xnxn00000gn/T/baremetal_test_693b035691f79 +Created go.mod with module name: baremetal_test_693b035691f79 +Created go_appserver.json +Created cmd/server/main.go +Created cmd/server/config.go +Created server/server.go +Created server/worker.go +Created server/pool.go +Created server/payload.go +Created php/worker.php +Created php/bridge.php +Created php/bootstrap_app.php +Go app server scaffolding complete. +Next steps: + 1. Run: go mod tidy + 2. Run: go run ./cmd/server + 3. Hit http://localhost:8080 + +/Users/elliotanderson/php/baremetalphp-src/tests/Feature/InstallGoAppServerCommandTest.php:142 + +8) Tests\Feature\InstallGoAppServerCommandTest::testGoModUsesCorrectModuleName +Test code or tested code printed unexpected output: Installing Go app server into: /private/var/folders/bk/l00_jcf56y92rzhh262xnxn00000gn/T/baremetal_test_693b03569294c +Created go.mod with module name: baremetal_test_693b03569294c +Created go_appserver.json +Created cmd/server/main.go +Created cmd/server/config.go +Created server/server.go +Created server/worker.go +Created server/pool.go +Created server/payload.go +Created php/worker.php +Created php/bridge.php +Created php/bootstrap_app.php +Go app server scaffolding complete. +Next steps: + 1. Run: go mod tidy + 2. Run: go run ./cmd/server + 3. Hit http://localhost:8080 + +/Users/elliotanderson/php/baremetalphp-src/tests/Feature/InstallGoAppServerCommandTest.php:317 + +9) Tests\Feature\InstallGoAppServerCommandTest::testPhpFilesContainCorrectContent +Test code or tested code printed unexpected output: Installing Go app server into: /private/var/folders/bk/l00_jcf56y92rzhh262xnxn00000gn/T/baremetal_test_693b035693355 +Created go.mod with module name: baremetal_test_693b035693355 +Created go_appserver.json +Created cmd/server/main.go +Created cmd/server/config.go +Created server/server.go +Created server/worker.go +Created server/pool.go +Created server/payload.go +Created php/worker.php +Created php/bridge.php +Created php/bootstrap_app.php +Go app server scaffolding complete. +Next steps: + 1. Run: go mod tidy + 2. Run: go run ./cmd/server + 3. Hit http://localhost:8080 + +/Users/elliotanderson/php/baremetalphp-src/tests/Feature/InstallGoAppServerCommandTest.php:233 + +10) Tests\Feature\MigrateCommandTest::testCreatesMigrationsTable +Test code or tested code printed unexpected output: No migration files found. + +/Users/elliotanderson/php/baremetalphp-src/tests/Feature/MigrateCommandTest.php:158 + +ERRORS! +Tests: 510, Assertions: 1005, Errors: 52, Failures: 9, PHPUnit Notices: 10, Skipped: 4, Risky: 10. diff --git a/resources/js/App.jsx b/resources/js/App.jsx deleted file mode 100644 index 4fab2d8..0000000 --- a/resources/js/App.jsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import { createRoot } from 'react-dom/client'; -import App from './App'; - -const container = document.getElementById('app'); -if (container) { - const component = container.dataset.component || 'App'; - const props = container.dataset.props ? JSON.parse(container.dataset.props) : {}; - - const root = createRoot(container); - root.render(); -} \ No newline at end of file diff --git a/src/Database/Builder.php b/src/Database/Builder.php index edba687..5917c56 100644 --- a/src/Database/Builder.php +++ b/src/Database/Builder.php @@ -32,6 +32,22 @@ class Builder */ protected array $orders = []; + /** + * Columns that are allowed to be used in ORDER BY clauses. + * + * If empty, a conservative identifier pattern will be used instead. + * + * @var string[] + */ + protected array $allowedOrderColumns = []; + + /** + * Columns that are safe to sort on via orderBy(). + * + * @var string[] $sortable + */ + protected array $sortable = []; + protected ?int $limit = null; protected ?int $offset = null; @@ -160,6 +176,10 @@ public function whereNotIn(string $column, array $values): self public function orderBy(string $column, string $direction = 'ASC'): self { + if (! $this->isAllowedOrderColumn($column)) { + throw new \InvalidArgumentException("Invalid order by column: {$column}"); + } + $direction = strtoupper($direction); if (!in_array($direction, ['ASC', 'DESC'], true)) { $direction = 'ASC'; @@ -181,6 +201,53 @@ public function offset(int $offset): self return $this; } + /** + * Determine if the given column is allowed in ORDER BY clauses. + * + * @param string $column + * @return bool + */ + protected function isAllowedOrderColumn(string $column): bool + { + // If an explicit allowlist has been set, enforce it strictly. + if (!empty($this->allowedOrderColumns)) { + return in_array($column, $this->allowedOrderColumns, true); + } + + // Fallback: allow only simple identifiers (no spaces, commas, operators, etc.) + // This blocks payloads like "name; DROP TABLE users" or "name DESC, (SELECT ...)". + return (bool) preg_match('/^[A-Za-z0-9_]+$/', $column); + } + + /** + * Optionally set an explicit allowlist of sortable columns. + * + * @param string[] $columns + * @return $this + */ + public function setAllowedOrderColumns(array $columns): self + { + $this->allowedOrderColumns = array_values(array_unique($columns)); + + return $this; + } + + + /** + * Get new rows for the current query without model hydration. + * + * @return array> + */ + public function getRows(): array + { + [$sql, $bindings] = $this->compileSelect(); + + $stmt = $this->pdo->prepare($sql); + $stmt->execute($bindings); + + return $stmt->fetchAll(); + } + protected function compileSelect(): array { $driver = $this->connection->getDriver(); diff --git a/src/Database/Entity.php b/src/Database/Entity.php new file mode 100644 index 0000000..4b85b6d --- /dev/null +++ b/src/Database/Entity.php @@ -0,0 +1,126 @@ + $original + */ + protected array $original = []; + + /** + * Attributers that have been modified since laad/flush. + * + * @var array>string,mixed> + */ + protected array $dirty = []; + + /** + * Mark this entity as having been loaded from a given row. + * + * @param array $row + * @return void + */ + public function hydrateFromRow(array $row): void + { + foreach ($row as $key => $value) { + // Assign directly; subclasse can override for casting if needed. + $this->{$key} = $value; + } + + $this->original = $row; + $this->dirty = []; + } + + /** + * Called by setters / property hooks to mark a field dirty. + * + * @param string $field + * @param mixed $value + * @return void + */ + protected function trackDirty(string $field, mixed $value): void + { + $this->dirty[$field] = $value; + } + + /** + * Get all dirty attributes. + * + * @return array + */ + public function getDirty(): array + { + return $this->dirty; + } + + /** + * Get all original attributes as last loaded from the database. + * + * @return array + */ + public function getOriginal(): array + { + return $this->original; + } + + /** + * Mark the entity as clean (after a successful insert/update). + * + * @return void + */ + public function markClean(): void + { + $data = $this->toArray(); + $this->original = $data; + $this->dirty = []; + } + + /** + * Determine if the entity represents a new row. + * + * @return bool + */ + public function isNew(): bool + { + $pk = static::primaryKey(); + return !isset($this->{$pk}); + } + + /** + * Get the entity attributes as an array. + * + * Default behavior uses geT_object_vars() and strip internal properties. + * + * @return array + */ + public function toArray(): array + { + $data = get_object_vars($this); + + unset($data['original'], $data['dirty']); + + return $data; + } + + /** + * Get the database table name for this entity. + * + * @return string + */ + abstract public static function table(): string; + + /** + * Get the primary key column for this entity. + * + * @return string + */ + abstract public static function primaryKey(): string; + +} \ No newline at end of file diff --git a/src/Database/EntityManager.php b/src/Database/EntityManager.php new file mode 100644 index 0000000..0c0d11d --- /dev/null +++ b/src/Database/EntityManager.php @@ -0,0 +1,183 @@ +connections->connection(); + $pdo = $connection->pdo(); + + $table = $entityClass::table(); + + // modelClass is null here, Data Mapper handles hydration + $builder = new Builder($pdo, $table, null, $connectionl); + + return new EntityQuery($entityClass, $builder, $this); + } + + public function find(string $entityClass, int|string $id): ?Entity + { + $query = $this->for($entityClass); + + $pk = $entityClass::primaryKey(); + + return $query->where($pk, '=', $id)->first(); + } + + public function save(Entity $entity): void + { + if ($entity->isNew()) { + $this->insert($entity); + } else { + $this->update($entity); + } + } + + public function delete(Entity $entity): void + { + $entityClass = $entity::class; + $table = $entityClass::table(); + $pk = $entityClass::primaryKey(); + + $pkValue = $entity->{$pk} ?? null; + if ($pkValue === null) { + return; + } + + $connection = $this->connections->connection(); + $pdo = $connection->pdo(); + $driver = $connection->getDriver(); + + $quotedTable = $driver->quoteIdentifier($table); + $quotedPk = $driver->quoteIdentifier($pk); + + $sql = "DELETE FROM {$quotedTable} WHERE {$quotedPk} = :id"; + + $stmt = $pdo->prepare($sql); + $stmt->execute(['id' => $pkValue]); + } + + protected function insert(Entity $entity): void + { + $entityClass = $entity::class; + $table = $entityClass::table(); + $pk = $entityClass::primaryKey(); + + $data = $entity->toArray(); + + // Never insert explicit primary key if it is null + if (!array_key_exists($pk, $data) || $data[$pk] === null) { + unset($data[$pk]); + } + + if (empty($data)) { + return; + } + + $connection = $this->connections->connection(); + $pdo = $connection->pdo(); + $driver = $connection->getDriver(); + + $quotedTable = $driver->quoteIdentifier($table); + + $columns = array_keys($data); + $placeholders = array_map(fn (string $col) => ':' . $col, $columns); + + $quotedColumns = array_map([$driver, 'quoteIdentifier'], $columns); + + $sql = sprintf( + 'INSERT INTO %s (%s) VALUES (%s)', + $quotedTable, + implode(', ', $quotedColumns), + implode(', ', $placeholders) + ); + + $stmt = $pdo->prepare($sql); + $stmt->execute($data); + + // Set PK if auto-increment + if (!isset($entity->{$pk})) { + $lastId = $pdo->lastInsertId(); + if ($lastId !== false) { + $entity->{$pk} = ctype_digit($lastId) ? (int) $lastId : $lastId; + } + } + + $entity->markClean(); + } + + protected function update(Entity $entity): void + { + $dirty = $entity->getDirty(); + if (empty($dirty)) { + return; + } + + $entityClass = $entity::class; + $table = $entityClass::table(); + $pk = $entityClass::primaryKey(); + + $pkValue = $entity->{$pk} ?? null; + if ($pkValue === null) { + throw new \RuntimeException(sprintf( + 'Cannot update %s without a primary key value.', + $entityClass + )); + } + + // Never try to update the PK + unset($dirty[$pk]); + + if (empty($dirty)) { + return; + } + + $connection = $this->connections->connection(); + $pdo = $connection->pdo(); + $driver = $connection->getDriver(); + + $quotedTable = $driver->quoteIdentifier($table); + + $assignments = []; + foreach (array_keys($dirty) as $column) { + $assignments[] = $driver->quoteIdentifier($column) . ' = :' . $column; + } + + $quotedPk = $driver->quoteIdentifier($pk); + + $sql = sprintf( + 'UPDATE %s SET %s WHERE %s = :_pk', + $quotedTable, + implode(', ', $assignments), + $quotedPk + ); + + $params = $dirty; + $params['_pk'] = $pkValue; + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + + $entity->markClean(); + } +} \ No newline at end of file diff --git a/src/Database/EntityQuery.php b/src/Database/EntityQuery.php new file mode 100644 index 0000000..cba6a06 --- /dev/null +++ b/src/Database/EntityQuery.php @@ -0,0 +1,81 @@ + $entityClass + */ + public function __construct( + protected string $entityClass, + protected Builder $builder, + protected EntityManager $em + ) { + } + + public function where(string $column, string $operator, mixed $value): self + { + $this->builder->where($column, $operator, $value); + return $this; + } + + public function orderBy(string $column, string $direction = 'ASC'): self + { + $this->builder->orderBy($column, $direction); + return $this; + } + + public function limit(int $limit): self + { + $this->builder->limit($limit); + return $this; + } + + public function offset(int $offset): self + { + $this->builder->offset($offset); + return $this; + } + + /** + * Get all matching entities. + * + * @return Collection + */ + public function get(): Collection + { + $rows = $this->builder->getRows(); + + $entities = array_map(function (array $row): Entity { + $class = $this->entityClass; + /** @var Entity $entity */ + $entity = new $class(); + $entity->hydrateFromRow($row); + return $entity; + }, $rows); + + return new Collection($entities); + } + + /** + * Get the first matching entity or null. + */ + public function first(): ?Entity + { + $this->builder->limit(1); + $rows = $this->builder->getRows(); + + if (empty($rows)) { + return null; + } + + $class = $this->entityClass; + /** @var Entity $entity */ + $entity = new $class(); + $entity->hydrateFromRow($rows[0]); + + return $entity; + } +} diff --git a/src/Database/Model.php b/src/Database/Model.php index 0e3e1c0..4c816d0 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -457,17 +457,6 @@ public function save(): bool return $this->performInsert(); } - /** - * Fill the model with an array of attributes - */ - public function fill(array $attributes): static - { - foreach ($attributes as $key => $value) { - $this->setAttribute($key, $value); - } - return $this; - } - /** * Update the model with an array of attributes */ diff --git a/src/Providers/DatabaseServiceProvider.php b/src/Providers/DatabaseServiceProvider.php index f47b72a..92bc17e 100644 --- a/src/Providers/DatabaseServiceProvider.php +++ b/src/Providers/DatabaseServiceProvider.php @@ -10,6 +10,8 @@ use BareMetalPHP\Support\ServiceProvider; use BareMetalPHP\Support\Config; +use BareMetalPHP\Database\EntityManager; + class DatabaseServiceProvider extends ServiceProvider { public function register(): void @@ -19,6 +21,12 @@ public function register(): void return new ConnectionManager(); }); + // Register EntityManager + $this->app->singleton(EntityManager::class, function () { + $manager = $this->app->make(ConnectionManager::class); + return new EntityManager($manager); + }); + // Register default connection $this->app->singleton(Connection::class, function () { $manager = $this->app->make(ConnectionManager::class); diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index 5aa60f6..0000000 --- a/vite.config.js +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; - -export default defineConfig({ - plugins: [react()], - build: { - outDir: 'public/build', - manifest: true, - rollupOptions: { - input: 'resources/js/app.jsx', - }, - }, - server: { - host: '0.0.0.0', - port: 5173, - strictPort: true, - hmr: { - host: 'localhost', - }, - }, -}); \ No newline at end of file From c5c810fc93a9d000154c6f305311087a5e401af8 Mon Sep 17 00:00:00 2001 From: Elliot Anderson Date: Thu, 11 Dec 2025 14:15:14 -0500 Subject: [PATCH 4/5] fixing changes that broke phpunit tests --- src/Application.php | 26 +++++-- src/Auth/Auth.php | 6 +- src/Database/Builder.php | 36 +++++++--- src/Database/Model.php | 12 +++- src/Database/SqlBuilder.php | 78 ++++++++++++++------- src/Support/Env.php | 5 ++ tests/Integration/FullStackTest.php | 1 + tests/Unit/AuthTest.php | 1 + tests/Unit/Database/BelongsToManyTest.php | 2 + tests/Unit/Database/BuilderAdvancedTest.php | 2 + tests/Unit/DatabaseRelationsTest.php | 4 ++ tests/Unit/ModelRelationshipsTest.php | 3 + tests/Unit/ModelTest.php | 1 + tests/Unit/MorphRelationshipsTest.php | 4 ++ tests/Unit/SqlBuilderTest.php | 4 +- 15 files changed, 143 insertions(+), 42 deletions(-) diff --git a/src/Application.php b/src/Application.php index c37aa72..0b3dcff 100644 --- a/src/Application.php +++ b/src/Application.php @@ -181,12 +181,30 @@ protected function resolveParameter(ReflectionParameter $param): mixed $type = $param->getType(); - // If no type or built-in type without default, we can't resolve it - if (! $type || $type->isBuiltin()) { - throw new \RuntimeException("Cannot resolve parameter {$param->getName()} of class " . ($param->getDeclaringClass() ? $param->getDeclaringClass()->getName() : 'unknown')); + // Named, non-union type + if ($type instanceof \ReflectionNamedType) { + // class / interface type -> let container build + if (! $type->isBuiltin()) { + return $this->make($type->getName()); + } + + // Built-in types (lenient for array/iterable) + $builtin = $type->getName(); + + if ($builtin === 'array' || $builtin === 'iterable') { + return []; + } } - return $this->make($type->getName()); + $owner = $param->getDeclaringClass()->getName() ?? 'unknown'; + + throw new \RuntimeException( + sprintf( + 'Cannot resolve parameter $%s of %s::__construct(). Either give it a default value or bind it explicitly to the container.', + $param->getName(), + $owner + ) + ); } public function registerProviders(array $providers): void diff --git a/src/Auth/Auth.php b/src/Auth/Auth.php index 65d9517..cf06c6e 100644 --- a/src/Auth/Auth.php +++ b/src/Auth/Auth.php @@ -41,7 +41,8 @@ public static function check(): bool */ public static function id(): ?int { - return Session::get(self::SESSION_KEY); + $userId = Session::get(self::SESSION_KEY); + return $userId ? (int)$userId : null; } /** @@ -50,7 +51,8 @@ public static function id(): ?int public static function login(User $user): void { Session::regenerate(); - Session::set(self::SESSION_KEY, (int)$user->getAttribute('id')); + $userId = $user->id ?? $user->getAttribute('id'); + Session::set(self::SESSION_KEY, $userId ? (int)$userId : null); } /** diff --git a/src/Database/Builder.php b/src/Database/Builder.php index 5917c56..6f8bdba 100644 --- a/src/Database/Builder.php +++ b/src/Database/Builder.php @@ -56,7 +56,7 @@ public function __construct(PDO $pdo, string $table, ?string $modelClass = null, $this->pdo = $pdo; $this->table = $table; $this->modelClass = $modelClass; - + // Store connection for driver access // If not provided, try to get it from a static connection if available if ($connection === null) { @@ -80,7 +80,7 @@ protected function createConnectionFromPdo(PDO $pdo): Connection $pdoProperty = $connection->getProperty('pdo'); $pdoProperty->setAccessible(true); $pdoProperty->setValue($instance, $pdo); - + return $instance; } @@ -111,7 +111,7 @@ public function __call(string $method, array $parameters): self /** * Specify relationships to eager load. - * + * * Example: * User::query()->with('posts', 'profile')->get() * User::query()->with(['posts', 'profile'])->get() @@ -162,17 +162,37 @@ public function orWhere(string $column, string $operator, mixed $value = null): public function whereIn(string $column, array $values): self { - $this->wheres[] = ['AND', $column, 'IN', $values]; + // empty values for IN, condition should match nothing but sql must still be valid + if (empty($values)) { + $this->wheres[] = ['AND', '1 = 0', 'RAW', null]; + + return $this; + } + + + $this->wheres[] = ['AND', $column, 'IN', array_values($values)]; return $this; } public function whereNotIn(string $column, array $values): self { - $this->wheres[] = ['AND', $column, 'NOT IN', $values]; + // Empty NOT IN matches everything (no restrictions). + if (empty($values)) { + // we can safely ignore it + return $this; + } + $this->wheres[] = ['AND', $column, 'NOT IN', array_values($values)]; + return $this; + } + + public function whereRaw(string $sql, string $boolean = 'AND'): self + { + $this->wheres[] = [$boolean, $sql, 'RAW', null]; + return $this; } - + public function orderBy(string $column, string $direction = 'ASC'): self { @@ -252,7 +272,7 @@ protected function compileSelect(): array { $driver = $this->connection->getDriver(); $quotedTable = $driver->quoteIdentifier($this->table); - + $sql = 'SELECT * FROM ' . $quotedTable; $bindings = []; @@ -324,5 +344,5 @@ public function toSql(): string return $sql; } - + } diff --git a/src/Database/Model.php b/src/Database/Model.php index 4c816d0..7897e65 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -77,9 +77,14 @@ abstract class Model implements ArrayAccess public function __construct(array $attributes = []) { - $this->fill($attributes); + // If attributes come from database (have 'id'), set them directly without fillable check if (isset($attributes['id'])) { $this->exists = true; + foreach ($attributes as $key => $value) { + $this->setAttribute($key, $value); + } + } else { + $this->fill($attributes); } } @@ -588,6 +593,11 @@ protected function performInsert(): bool [$connection, $driver, $pdo] = $this->getConnectionComponents(); $preparedAttributes = $this->prepareAttributes($this->attributes, $driver); + // Prevent empty INSERT statements + if (empty($preparedAttributes)) { + return false; + } + $columns = array_keys($preparedAttributes); $quotedColumns = $this->quoteIdentifiers($columns, $driver); $placeholders = array_map(fn ($c) => ':' . $c, $columns); diff --git a/src/Database/SqlBuilder.php b/src/Database/SqlBuilder.php index 255988b..27b6c34 100644 --- a/src/Database/SqlBuilder.php +++ b/src/Database/SqlBuilder.php @@ -24,44 +24,72 @@ public static function buildWhereClause( } $parts = []; + foreach ($wheres as $index => [$boolean, $column, $operator, $value]) { + $boolean = strtoupper($boolean); + $operator = strtoupper($operator); + + // First condition uses WHERE instead of AND/OR + $prefix = $index === 0 ? 'WHERE' : $boolean; + + // RAW clauses are passed through untouched + if ($operator === 'RAW') { + $parts[] = $prefix . ' ' . $column; + continue; + } + $quotedColumn = $driver->quoteIdentifier($column); - // Handle NULL values + // Handle IN / NOT IN explicitly (including empty arrays) + if ($operator === 'IN' || $operator === 'NOT IN') { + $values = (array) $value; + + if (empty($values)) { + // IN () => always false, NOT IN () => always true + $clause = $operator === 'IN' ? '1 = 0' : '1 = 1'; + $parts[] = $prefix . ' ' . $clause; + continue; + } + + $placeholders = implode(', ', array_fill(0, count($values), '?')); + $clause = sprintf( + '%s %s (%s)', + $quotedColumn, + $operator, + $placeholders + ); + + foreach ($values as $v) { + $bindings[] = $v; + } + + $parts[] = $prefix . ' ' . $clause; + continue; + } + + // Handle NULL values for normal comparison operators if ($value === null) { - if (strtoupper($operator) === '=' || strtoupper($operator) === 'IS') { + if (in_array($operator, ['=', 'IS'], true)) { $clause = $quotedColumn . ' IS NULL'; - } elseif (strtoupper($operator) === '!=' || strtoupper($operator) === '<>' || strtoupper($operator) === 'IS NOT') { + } elseif (in_array($operator, ['!=', '<>', 'IS NOT'], true)) { $clause = $quotedColumn . ' IS NOT NULL'; } else { $clause = $quotedColumn . ' ' . $operator . ' NULL'; } - // No binding needed for NULL - } else { - $preparedValue = $driver->prepareValue($value); - - if (strtoupper($operator) === 'IN' && is_array($preparedValue)) { - $placeholders = implode(',', array_fill(0, count($preparedValue), '?')); - $clause = $quotedColumn . ' IN (' . $placeholders . ')'; - $bindings = array_merge($bindings, $preparedValue); - } elseif (strtoupper($operator) === 'NOT IN' && is_array($preparedValue)) { - $placeholders = implode(',', array_fill(0, count($preparedValue), '?')); - $clause = $quotedColumn . ' NOT IN (' . $placeholders . ')'; - $bindings = array_merge($bindings, $preparedValue); - } else { - $clause = $quotedColumn . ' ' . $operator . ' ?'; - $bindings[] = $preparedValue; - } - } - if ($index === 0) { - $parts[] = $clause; - } else { - $parts[] = $boolean . ' ' . $clause; + // No binding for NULL + $parts[] = $prefix . ' ' . $clause; + continue; } + + // Standard [column operator ?] clause + $clause = $quotedColumn . ' ' . $operator . ' ?'; + $bindings[] = $value; + + $parts[] = $prefix . ' ' . $clause; } - return ' WHERE ' . implode(' ', $parts); + return ' ' . implode(' ', $parts); } /** diff --git a/src/Support/Env.php b/src/Support/Env.php index 289ac53..0eaa9b1 100644 --- a/src/Support/Env.php +++ b/src/Support/Env.php @@ -6,6 +6,11 @@ final class Env { + /** + * Track if environment has been loaded (for testing purposes) + */ + protected static bool $loaded = false; + /** * Load environment variables from a file. * diff --git a/tests/Integration/FullStackTest.php b/tests/Integration/FullStackTest.php index 6235a57..7e4cab8 100644 --- a/tests/Integration/FullStackTest.php +++ b/tests/Integration/FullStackTest.php @@ -86,5 +86,6 @@ public function register(): void class TestUser extends Model { protected static string $table = 'users'; + protected array $fillable = ['name', 'email']; } diff --git a/tests/Unit/AuthTest.php b/tests/Unit/AuthTest.php index f5a735b..946a9b8 100644 --- a/tests/Unit/AuthTest.php +++ b/tests/Unit/AuthTest.php @@ -13,6 +13,7 @@ class User extends Model { protected static string $table = 'users'; + protected array $fillable = ['name', 'email', 'password']; } class AuthTest extends TestCase diff --git a/tests/Unit/Database/BelongsToManyTest.php b/tests/Unit/Database/BelongsToManyTest.php index 2128168..f629124 100644 --- a/tests/Unit/Database/BelongsToManyTest.php +++ b/tests/Unit/Database/BelongsToManyTest.php @@ -381,11 +381,13 @@ class TestUserForBelongsToMany extends Model { protected static string $table = 'users'; protected bool $timestamps = false; + protected array $fillable = ['name']; } class TestRole extends Model { protected static string $table = 'roles'; protected bool $timestamps = false; + protected array $fillable = ['name']; } diff --git a/tests/Unit/Database/BuilderAdvancedTest.php b/tests/Unit/Database/BuilderAdvancedTest.php index 81a2ef1..29c09cf 100644 --- a/tests/Unit/Database/BuilderAdvancedTest.php +++ b/tests/Unit/Database/BuilderAdvancedTest.php @@ -257,6 +257,7 @@ class TestUserForEager extends Model { protected static string $table = 'users'; protected bool $timestamps = false; + protected array $fillable = ['name', 'email', 'age', 'status']; public function posts() { @@ -272,6 +273,7 @@ public function profile() class TestPostForEager extends Model { protected static string $table = 'posts'; + protected array $fillable = ['user_id', 'title']; protected bool $timestamps = false; } diff --git a/tests/Unit/DatabaseRelationsTest.php b/tests/Unit/DatabaseRelationsTest.php index 33927fe..9972695 100644 --- a/tests/Unit/DatabaseRelationsTest.php +++ b/tests/Unit/DatabaseRelationsTest.php @@ -17,12 +17,14 @@ class RelationUser extends Model { protected static string $table = 'users'; protected bool $timestamps = false; + protected array $fillable = ['name', 'email']; } class RelationPost extends Model { protected static string $table = 'posts'; protected bool $timestamps = false; + protected array $fillable = ['user_id', 'title']; public function user() { @@ -34,6 +36,7 @@ class RelationProfile extends Model { protected static string $table = 'profiles'; protected bool $timestamps = false; + protected array $fillable = ['user_id', 'bio']; public function user() { @@ -45,6 +48,7 @@ class RelationUserWithRelations extends Model { protected static string $table = 'users'; protected bool $timestamps = false; + protected array $fillable = ['name', 'email']; public function posts() { diff --git a/tests/Unit/ModelRelationshipsTest.php b/tests/Unit/ModelRelationshipsTest.php index 561b179..1bb66e0 100644 --- a/tests/Unit/ModelRelationshipsTest.php +++ b/tests/Unit/ModelRelationshipsTest.php @@ -169,6 +169,7 @@ class UserWithRelations extends Model protected static string $table = 'users'; protected bool $timestamps = false; + protected array $fillable = ['name', 'email']; public function posts() { @@ -186,6 +187,7 @@ class PostForRelations extends Model protected static string $table = 'posts'; protected bool $timestamps = false; + protected array $fillable = ['user_id', 'title', 'body']; public function user() { @@ -196,6 +198,7 @@ public function user() class ProfileForRelations extends Model { protected static string $table = 'profiles'; + protected array $fillable = ['user_id', 'bio']; protected bool $timestamps = false; public function user() diff --git a/tests/Unit/ModelTest.php b/tests/Unit/ModelTest.php index f4a30c3..c030b7c 100644 --- a/tests/Unit/ModelTest.php +++ b/tests/Unit/ModelTest.php @@ -226,6 +226,7 @@ public function testCanConvertToArray(): void class TestUser extends Model { protected static string $table = 'users'; + protected array $fillable = ['name', 'email']; public function getDisplayNameAttribute(): string { diff --git a/tests/Unit/MorphRelationshipsTest.php b/tests/Unit/MorphRelationshipsTest.php index 97596ea..77bd2f6 100644 --- a/tests/Unit/MorphRelationshipsTest.php +++ b/tests/Unit/MorphRelationshipsTest.php @@ -208,22 +208,26 @@ class Post extends Model { protected static string $table = 'posts'; protected bool $timestamps = false; + protected array $fillable = ['title', 'commentable_type', 'commentable_id']; } class Video extends Model { protected static string $table = 'videos'; protected bool $timestamps = false; + protected array $fillable = ['title', 'commentable_type', 'commentable_id']; } class Comment extends Model { protected static string $table = 'comments'; protected bool $timestamps = false; + protected array $fillable = ['body', 'commentable_type', 'commentable_id']; } class Image extends Model { protected static string $table = 'images'; protected bool $timestamps = false; + protected array $fillable = ['url', 'imageable_type', 'imageable_id']; } diff --git a/tests/Unit/SqlBuilderTest.php b/tests/Unit/SqlBuilderTest.php index c5bffde..f49417a 100644 --- a/tests/Unit/SqlBuilderTest.php +++ b/tests/Unit/SqlBuilderTest.php @@ -87,7 +87,7 @@ public function testBuildWhereClauseWithInOperator(): void $clause = SqlBuilder::buildWhereClause($wheres, $this->driver, $bindings); $this->assertStringContainsString('IN', $clause); - $this->assertStringContainsString('(?,?,?)', $clause); + $this->assertStringContainsString('(?, ?, ?)', $clause); $this->assertCount(3, $bindings); $this->assertSame([1, 2, 3], $bindings); } @@ -102,7 +102,7 @@ public function testBuildWhereClauseWithNotInOperator(): void $clause = SqlBuilder::buildWhereClause($wheres, $this->driver, $bindings); $this->assertStringContainsString('NOT IN', $clause); - $this->assertStringContainsString('(?,?,?)', $clause); + $this->assertStringContainsString('(?, ?, ?)', $clause); $this->assertCount(3, $bindings); } From dcfd54e940af21a4cb5cfcfd60bc4b77ed01a400 Mon Sep 17 00:00:00 2001 From: Elliot Anderson Date: Thu, 11 Dec 2025 17:58:39 -0500 Subject: [PATCH 5/5] seeder bare class --- src/Database/Seeder/Seeder.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/Database/Seeder/Seeder.php diff --git a/src/Database/Seeder/Seeder.php b/src/Database/Seeder/Seeder.php new file mode 100644 index 0000000..f7718de --- /dev/null +++ b/src/Database/Seeder/Seeder.php @@ -0,0 +1,14 @@ +run(); + } +} \ No newline at end of file