From b27772a96c641ea7d1bd50945662b5ecc69f9534 Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Tue, 14 Apr 2026 16:21:43 -0400 Subject: [PATCH] feat: auto-convert camelCase property names to snake_case column names When a #[Column] attribute has no explicit name: parameter, the property name is now automatically converted from camelCase to snake_case for the database column name (e.g., $createdAt -> created_at, $userId -> user_id). Explicit #[Column(name: 'custom')] overrides still take priority. All redundant explicit name: parameters removed from production entities across admin-auth, media, webhook, and authentication-token packages. Documentation updated with new convention. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../001-core-camel-to-snake.md | 33 +++++ .../002-database-unit-tests.md | 33 +++++ .../003-database-feature-tests.md | 42 +++++++ .../004-repository-tests.md | 40 ++++++ .../005-production-entity-cleanup.md | 114 ++++++++++++++++++ .../006-documentation.md | 45 +++++++ .../_devils_advocate.md | 85 +++++++++++++ .claude/plans/camel-to-snake-columns/_plan.md | 60 +++++++++ docs/src/content/docs/guides/database.md | 2 +- docs/src/content/docs/packages/database.md | 8 +- .../content/docs/tutorials/build-a-blog.md | 4 +- .../content/docs/tutorials/build-a-chat.md | 2 +- .../docs/tutorials/build-a-rest-api.md | 6 +- .../docs/tutorials/build-an-admin-panel.md | 18 +-- .../content/docs/tutorials/custom-module.md | 4 +- packages/admin-auth/src/Entity/AdminUser.php | 8 +- packages/admin-auth/src/Entity/Permission.php | 2 +- packages/admin-auth/src/Entity/Role.php | 6 +- .../admin-auth/src/Entity/RolePermission.php | 4 +- .../tests/Unit/Entity/AdminUserTest.php | 5 +- .../tests/Unit/Entity/PermissionTest.php | 10 +- .../tests/Unit/Entity/RolePermissionTest.php | 27 ++--- .../admin-auth/tests/Unit/Entity/RoleTest.php | 20 ++- .../src/Entity/PersonalAccessToken.php | 12 +- .../tests/Entity/PersonalAccessTokenTest.php | 12 +- packages/database/README.md | 4 +- .../src/Entity/EntityMetadataFactory.php | 22 +++- .../Entity/EntityMetadataFactoryTest.php | 96 ++++++++++++++- .../tests/Entity/SchemaBuilderTest.php | 4 +- .../Feature/EntityToMigrationWorkflowTest.php | 11 +- .../tests/Feature/RepositoryCrudTest.php | 14 +-- .../tests/Repository/RepositoryTest.php | 30 ++--- packages/media/src/Entity/Media.php | 8 +- packages/media/src/Entity/MediaAttachment.php | 6 +- packages/media/tests/Entity/MediaTest.php | 8 +- .../webhook/src/Entity/WebhookAttempt.php | 12 +- 36 files changed, 685 insertions(+), 132 deletions(-) create mode 100644 .claude/plans/camel-to-snake-columns/001-core-camel-to-snake.md create mode 100644 .claude/plans/camel-to-snake-columns/002-database-unit-tests.md create mode 100644 .claude/plans/camel-to-snake-columns/003-database-feature-tests.md create mode 100644 .claude/plans/camel-to-snake-columns/004-repository-tests.md create mode 100644 .claude/plans/camel-to-snake-columns/005-production-entity-cleanup.md create mode 100644 .claude/plans/camel-to-snake-columns/006-documentation.md create mode 100644 .claude/plans/camel-to-snake-columns/_devils_advocate.md create mode 100644 .claude/plans/camel-to-snake-columns/_plan.md diff --git a/.claude/plans/camel-to-snake-columns/001-core-camel-to-snake.md b/.claude/plans/camel-to-snake-columns/001-core-camel-to-snake.md new file mode 100644 index 00000000..c892fb24 --- /dev/null +++ b/.claude/plans/camel-to-snake-columns/001-core-camel-to-snake.md @@ -0,0 +1,33 @@ +# Task 001: Add camelToSnakeCase and Update EntityMetadataFactory + +**Status**: completed +**Depends on**: none +**Retry count**: 0 + +## Description +Add a private `camelToSnakeCase(string $name): string` method to `EntityMetadataFactory` and update the `parse()` method to auto-convert property names to snake_case when no explicit `name:` is provided on the `#[Column]` attribute. + +## Context +- Key file: `packages/database/src/Entity/EntityMetadataFactory.php` +- Line 68 is the critical change: `$columnName = $columnAttr->name ?? $propertyName;` becomes `$columnName = $columnAttr->name ?? $this->camelToSnakeCase($propertyName);` +- Test file: `packages/database/tests/Entity/EntityMetadataFactoryTest.php` +- The existing test "uses Column attribute name when specified" (line 172) tests explicit `name:` override — it should still pass +- Single-word properties like `$id`, `$name`, `$email` should be unaffected (already lowercase) + +## Requirements (Test Descriptions) +- [ ] `it converts camelCase property names to snake_case column names automatically` +- [ ] `it preserves explicit Column name override when specified` +- [ ] `it handles single-word property names without change` +- [ ] `it handles consecutive uppercase letters correctly (userID becomes user_id)` +- [ ] `it handles leading uppercase sequences correctly (HTMLParser becomes html_parser)` +- [ ] `it updates the existing override test to use a genuinely custom name` + +## Acceptance Criteria +- All requirements have passing tests +- `camelToSnakeCase` handles: `postId` -> `post_id`, `createdAt` -> `created_at`, `id` -> `id`, `HTMLParser` -> `html_parser`, `userID` -> `user_id`, `isActive` -> `is_active` +- Explicit `#[Column(name: 'custom')]` still takes priority over auto-conversion +- Existing EntityMetadataFactoryTest tests still pass (some may need column name assertion updates) +- The existing "uses Column attribute name when specified" test (line 172) MUST be updated to use a genuinely custom name that differs from what auto-conversion would produce (e.g., `#[Column(name: 'author')]` on `$userId` instead of `#[Column(name: 'user_id')]`), otherwise the test becomes a no-op that passes whether or not the override works + +## Implementation Notes +(Left blank - filled in by programmer during implementation) diff --git a/.claude/plans/camel-to-snake-columns/002-database-unit-tests.md b/.claude/plans/camel-to-snake-columns/002-database-unit-tests.md new file mode 100644 index 00000000..32797a15 --- /dev/null +++ b/.claude/plans/camel-to-snake-columns/002-database-unit-tests.md @@ -0,0 +1,33 @@ +# Task 002: Update Database Package Unit Tests + +**Status**: completed +**Depends on**: 001 +**Retry count**: 0 + +## Description +Update unit tests in the database package that assert column names from parsed metadata. The core change causes camelCase property names to produce snake_case column names, so assertions checking column names need updating. + +## Context +- `packages/database/tests/Entity/EntityMetadataFactoryTest.php` — Some test entities use camelCase properties without explicit names. The "extracts #[Column] attributes" test uses `$title`, `$content` (single-word, no change). The "extracts foreign key reference" test uses `$userId` without explicit name — its column will now be `user_id` instead of `userId`. But this test only checks `references`/`onDelete`/`onUpdate`, not the column name, so it should still pass. +- `packages/database/tests/Entity/SchemaBuilderTest.php` — The "builds ForeignKey objects from column references" test (line 138) expects FK name `fk_posts_userId` and columns `['userId']`. After the change, `$userId` (no explicit name) auto-converts to `user_id`, so expect `fk_posts_user_id` and `['user_id']`. The "preserves foreign key references" test (line 119) doesn't check column name directly — only references/onDelete/onUpdate — so it should pass. +- `packages/database/tests/Entity/EntityMetadataTest.php` — Manually constructs PropertyMetadata/ColumnMetadata, doesn't use the factory. No changes needed. +- `packages/database/tests/Entity/EntityHydratorTest.php` — Manually constructs metadata with snake_case column names. No changes needed. +- `packages/database/tests/Attributes/ColumnAttributeTest.php` — Tests the Column attribute class directly, not the factory. No changes needed. +- `packages/database/tests/Entity/EntityDiscoveryTest.php` — Uses `$id` only. No changes. +- `packages/database/tests/Schema/SchemaRegistryTest.php` — Uses single-word properties only. No changes. + +## Requirements (Test Descriptions) +- [ ] `it generates FK name using snake_case column name (fk_posts_user_id)` +- [ ] `it uses snake_case column names in FK column arrays` +- [ ] `it preserves column name assertions for single-word properties` +- [ ] `it passes all existing EntityMetadataFactory tests after column name conversion` + +## Acceptance Criteria +- All tests in `packages/database/tests/Entity/` pass +- All tests in `packages/database/tests/Schema/` pass +- All tests in `packages/database/tests/Attributes/` pass +- FK name in SchemaBuilderTest updated from `fk_posts_userId` to `fk_posts_user_id` +- FK columns in SchemaBuilderTest updated from `['userId']` to `['user_id']` + +## Implementation Notes +(Left blank - filled in by programmer during implementation) diff --git a/.claude/plans/camel-to-snake-columns/003-database-feature-tests.md b/.claude/plans/camel-to-snake-columns/003-database-feature-tests.md new file mode 100644 index 00000000..bbe5cf53 --- /dev/null +++ b/.claude/plans/camel-to-snake-columns/003-database-feature-tests.md @@ -0,0 +1,42 @@ +# Task 003: Update Database Package Feature Tests + +**Status**: completed +**Depends on**: 001 +**Retry count**: 0 + +## Description +Update feature tests in the database package that use inline entity classes with camelCase properties. After the core change, these properties produce snake_case column names, breaking assertions that reference the old camelCase column names. + +## Context +- `packages/database/tests/Feature/EntityToMigrationWorkflowTest.php`: + - `WorkflowUser` has `$isActive` with bare `#[Column]` — column becomes `is_active` instead of `isActive` + - `WorkflowPost` has `$authorId` with bare `#[Column]` — column becomes `author_id` instead of `authorId` + - Line 230: `->toContain('isActive')` must become `->toContain('is_active')` + - Line 237: `->toContain('authorId')` must become `->toContain('author_id')` + - Line 267: `new SchemaColumn(name: 'isActive', type: 'BOOLEAN')` must become `name: 'is_active'` + - Line 289: `->toContain('isActive')` must become `->toContain('is_active')` + - IMPORTANT: Search the entire file for ALL `SchemaColumn` and `SchemaTable` constructions that represent entity-derived schema. Any manually constructed `SchemaColumn` with a camelCase `name:` that represents what the entity would produce must also be updated (e.g., `name: 'isActive'` -> `name: 'is_active'`, `name: 'authorId'` -> `name: 'author_id'`). The "detects and generates migrations" test constructs expected schema objects that must match the new auto-converted names. + +- `packages/database/tests/Feature/RepositoryCrudTest.php`: + - `CrudProduct` has `$isAvailable` with bare `#[Column]` — column becomes `is_available` instead of `isAvailable` + - Line 107: Mock storage uses `'isAvailable'` key — must become `'is_available'` + - Lines 203-205: Mock storage arrays use `'isAvailable'` key — must become `'is_available'` + - Line 228: `str_contains($sql, 'isAvailable = ?')` must become `str_contains($sql, 'is_available = ?')` + - Line 370: Mock storage uses `'isAvailable'` key — must become `'is_available'` + +## Requirements (Test Descriptions) +- [ ] `it uses snake_case column names in entity-to-migration workflow assertions` +- [ ] `it uses snake_case column names in SchemaColumn construction for diff tests` +- [ ] `it uses snake_case column names in mock storage arrays for CRUD tests` +- [ ] `it uses snake_case column names in SQL string matching for findBy tests` +- [ ] `it passes all feature tests after column name updates` + +## Acceptance Criteria +- All tests in `packages/database/tests/Feature/` pass +- All `isActive` column references updated to `is_active` +- All `authorId` column references updated to `author_id` +- All `isAvailable` column references updated to `is_available` +- Mock connection storage arrays use snake_case keys for column names + +## Implementation Notes +(Left blank - filled in by programmer during implementation) diff --git a/.claude/plans/camel-to-snake-columns/004-repository-tests.md b/.claude/plans/camel-to-snake-columns/004-repository-tests.md new file mode 100644 index 00000000..35cef157 --- /dev/null +++ b/.claude/plans/camel-to-snake-columns/004-repository-tests.md @@ -0,0 +1,40 @@ +# Task 004: Update Repository Tests + +**Status**: completed +**Depends on**: 001 +**Retry count**: 0 + +## Description +Update Repository unit tests that use entities with camelCase properties. After the core change, the Repository generates SQL using snake_case column names, so mock connections and assertions need updating. + +## Context +- `packages/database/tests/Repository/RepositoryTest.php`: + - `RepositoryTestUser` has `$isActive` with bare `#[Column]` — column becomes `is_active` instead of `isActive` + - `$email` with `#[Column('email_address')]` — explicitly named, no change needed + - There are **23 occurrences** of `isActive` in this file that need review. Affected areas: + - Line 188: Comment `'isActive' maps to 'isActive' column` — update comment to reflect snake_case + - Lines 194, 233, 282: Mock connection storage arrays with `'isActive'` key — change to `'is_active'` + - Lines 310, 311, 327, 328, 343: Mock query return arrays with `'isActive'` key — change to `'is_active'` + - Line 334: `findBy(['isActive' => true])` — this uses the PHP property name as the criteria key, which Repository::findBy maps to column name via `$propertyToColumn`. The call stays as `findBy(['isActive' => true])` (property name), but the generated SQL will now contain `is_active = ?` instead of `isActive = ?`, so mock SQL matching must update accordingly. + - Lines 412, 452, 527, 626, 665, 724, 819: Various mock data arrays with `'isActive'` key — change to `'is_active'` + - Line 577: `->not->toContain('isActive')` — this checks SQL SET clause; update to `'is_active'` + - NOTE: Lines that reference `$user->isActive` (PHP property access) do NOT change — only column name string keys in mock data arrays and SQL assertions change + +- `packages/database/tests/Repository/RepositoryLifecycleEventTest.php`: + - `LifecycleTestItem` only has `$id` and `$name` (single-word properties) — no changes needed + - Verify test still passes after the core change + +## Requirements (Test Descriptions) +- [ ] `it uses snake_case column names in Repository SQL generation assertions` +- [ ] `it uses snake_case column names in mock connection storage arrays` +- [ ] `it preserves explicit Column name override (email_address) in Repository tests` +- [ ] `it passes all Repository tests after column name updates` + +## Acceptance Criteria +- All tests in `packages/database/tests/Repository/` pass +- All `isActive` column references in mock code updated to `is_active` +- Explicit `email_address` mapping unchanged +- RepositoryLifecycleEventTest passes without changes + +## Implementation Notes +(Left blank - filled in by programmer during implementation) diff --git a/.claude/plans/camel-to-snake-columns/005-production-entity-cleanup.md b/.claude/plans/camel-to-snake-columns/005-production-entity-cleanup.md new file mode 100644 index 00000000..6b0588ad --- /dev/null +++ b/.claude/plans/camel-to-snake-columns/005-production-entity-cleanup.md @@ -0,0 +1,114 @@ +# Task 005: Remove Redundant Names from Production Entities and Update Entity Tests + +**Status**: completed +**Depends on**: 001 +**Retry count**: 0 + +## Description +Remove all explicit `#[Column(name: '...')]` parameters that are now redundant because the auto-conversion produces the same snake_case name. Update entity attribute tests in admin-auth, authentication-token, and media that check Column attribute `name` via reflection. + +## Context +All explicit names in production entities match what the auto-conversion would produce. They are now unnecessary boilerplate. + +**Entities to clean up:** + +- `packages/admin-auth/src/Entity/AdminUser.php`: + - `#[Column('remember_token')]` on `$rememberToken` → `#[Column]` + - `#[Column('is_active', default: '1')]` on `$isActive` → `#[Column(default: '1')]` + - `#[Column('created_at')]` on `$createdAt` → `#[Column]` + - `#[Column('updated_at')]` on `$updatedAt` → `#[Column]` + +- `packages/admin-auth/src/Entity/Role.php`: + - `#[Column('is_super_admin', default: '0')]` on `$isSuperAdmin` → `#[Column(default: '0')]` + - `#[Column('created_at')]` on `$createdAt` → `#[Column]` + - `#[Column('updated_at')]` on `$updatedAt` → `#[Column]` + +- `packages/admin-auth/src/Entity/Permission.php`: + - `#[Column('created_at')]` on `$createdAt` → `#[Column]` + +- `packages/admin-auth/src/Entity/RolePermission.php`: + - `#[Column('role_id', references: 'roles.id', onDelete: 'CASCADE')]` on `$roleId` → `#[Column(references: 'roles.id', onDelete: 'CASCADE')]` + - `#[Column('permission_id', references: 'permissions.id', onDelete: 'CASCADE')]` on `$permissionId` → `#[Column(references: 'permissions.id', onDelete: 'CASCADE')]` + +- `packages/media/src/Entity/Media.php`: + - `#[Column('original_filename', length: 255)]` on `$originalFilename` → `#[Column(length: 255)]` + - `#[Column('mime_type', length: 100)]` on `$mimeType` → `#[Column(length: 100)]` + - `#[Column('created_at')]` on `$createdAt` → `#[Column]` + - `#[Column('updated_at')]` on `$updatedAt` → `#[Column]` + +- `packages/media/src/Entity/MediaAttachment.php`: + - `#[Column('media_id')]` on `$mediaId` → `#[Column]` + - `#[Column('attachable_type', length: 255)]` on `$attachableType` → `#[Column(length: 255)]` + - `#[Column('attachable_id', length: 255)]` on `$attachableId` → `#[Column(length: 255)]` + +- `packages/webhook/src/Entity/WebhookAttempt.php`: + - `#[Column(name: 'status_code')]` on `$statusCode` → `#[Column]` + - `#[Column(name: 'response_body', type: 'TEXT')]` on `$responseBody` → `#[Column(type: 'TEXT')]` + - `#[Column(name: 'error_message', type: 'TEXT')]` on `$errorMessage` → `#[Column(type: 'TEXT')]` + - `#[Column(name: 'attempted_at')]` on `$attemptedAt` → `#[Column]` + - `#[Column(name: 'webhook_url')]` on `$webhookUrl` → `#[Column]` + - `#[Column(name: 'attempt_number')]` on `$attemptNumber` → `#[Column]` + +- `packages/authentication-token/src/Entity/PersonalAccessToken.php`: + - `#[Column('tokenable_type')]` on `$tokenableType` → `#[Column]` + - `#[Column('tokenable_id')]` on `$tokenableId` → `#[Column]` + - `#[Column('token_hash', length: 64)]` on `$tokenHash` → `#[Column(length: 64)]` + - `#[Column('last_used_at')]` on `$lastUsedAt` → `#[Column]` + - `#[Column('expires_at')]` on `$expiresAt` → `#[Column]` + - `#[Column('created_at')]` on `$createdAt` → `#[Column]` + +- `packages/database/tests/Entity/EntityMetadataFactoryTest.php`: + - Line 175: `#[Column(name: 'user_id')]` on `$userId` — this is now redundant. Remove `name:` parameter. + - NOTE: The "uses Column attribute name when specified" test reworking is handled in task 001. + +- `packages/database/tests/Entity/EntityHydratorTest.php`: + - Line 25: `#[Column('email_address')]` on `$email` — NOT redundant (`email` -> `email`, not `email_address`). Keep as-is. + +- `packages/database/tests/Repository/RepositoryTest.php`: + - Line 32: `#[Column('email_address')]` on `$email` — NOT redundant. Keep as-is. + +**Tests to update (check Column attribute `name` via reflection):** + +- `packages/admin-auth/tests/Unit/Entity/AdminUserTest.php`: + - Line 67: `expect($rememberTokenColumn->name)->toBe('remember_token')` — after removal, `$columnAttr->name` will be `null`. Update test to check `null` or remove assertion (the factory handles the conversion, not the attribute). + - Line 72: `expect($isActiveColumn->name)->toBe('is_active')` — same issue. After removal of explicit name from `#[Column(default: '1')]`, `name` is `null`. + +- `packages/admin-auth/tests/Unit/Entity/PermissionTest.php`: + - Line 117: `expect($columnAttribute->name)->toBe('created_at')` — same issue. + +- `packages/admin-auth/tests/Unit/Entity/RoleTest.php`: + - Lines 151, 165: `expect($columnAttribute->name)->toBe('created_at')` and `'updated_at'` — same issue. + +- `packages/authentication-token/tests/Entity/PersonalAccessTokenTest.php`: + - Line 38: `$tokenableTypeColumn->name->toBe('tokenable_type')` — will be `null` after removal. Update to `->toBeNull()` or remove. + - Line 45: `$tokenableIdColumn->name->toBe('tokenable_id')` — same issue. + - Line 57: `$tokenHashColumn->name->toBe('token_hash')` — same issue. + - Line 72: `$lastUsedAtColumn->name->toBe('last_used_at')` — same issue. + - Line 79: `$expiresAtColumn->name->toBe('expires_at')` — same issue. + - Line 86: `$createdAtColumn->name->toBe('created_at')` — same issue. + +- `packages/media/tests/Entity/MediaTest.php`: + - Line 45: `$originalFilenameColumn->name->toBe('original_filename')` — will be `null` after removal. Update to `->toBeNull()` or remove. + - Line 53: `$mimeTypeColumn->name->toBe('mime_type')` — same issue. + - Line 88: `$createdAtColumn->name->toBe('created_at')` — same issue. + - Line 96: `$updatedAtColumn->name->toBe('updated_at')` — same issue. + +## Requirements (Test Descriptions) +- [ ] `it removes redundant explicit Column name from admin-auth entities` +- [ ] `it removes redundant explicit Column name from media entities` +- [ ] `it removes redundant explicit Column name from webhook entity` +- [ ] `it removes redundant explicit Column name from authentication-token entity` +- [ ] `it updates admin-auth entity tests to not assert explicit Column attribute name` +- [ ] `it updates authentication-token entity test to not assert explicit Column attribute name` +- [ ] `it updates media entity test to not assert explicit Column attribute name` +- [ ] `it passes all tests across affected packages after cleanup` + +## Acceptance Criteria +- No redundant explicit `name:` parameters remain in production entity `#[Column]` attributes +- Admin-auth entity tests updated to reflect that Column attribute `name` is now `null` (auto-derived by factory) +- Authentication-token entity test updated to reflect that Column attribute `name` is now `null` (auto-derived by factory) +- Media entity test updated to reflect that Column attribute `name` is now `null` (auto-derived by factory) +- All tests pass across: database, admin-auth, media, webhook, authentication-token, notification-database packages + +## Implementation Notes +(Left blank - filled in by programmer during implementation) diff --git a/.claude/plans/camel-to-snake-columns/006-documentation.md b/.claude/plans/camel-to-snake-columns/006-documentation.md new file mode 100644 index 00000000..63d07f3b --- /dev/null +++ b/.claude/plans/camel-to-snake-columns/006-documentation.md @@ -0,0 +1,45 @@ +# Task 006: Update Documentation + +**Status**: completed +**Depends on**: 001, 005 +**Retry count**: 0 + +## Description +Update all documentation to reflect the new auto-conversion convention: camelCase property names are automatically converted to snake_case column names. Remove redundant explicit `name:` parameters from doc examples and add explanation of the convention. + +## Context +- `docs/src/content/docs/guides/database.md` — Entity examples, mention auto-conversion +- `docs/src/content/docs/packages/database.md` — Column attribute docs, document convention, note `name:` override +- `docs/src/content/docs/packages/database-mysql.md` — Update examples if any use explicit names +- `docs/src/content/docs/packages/database-pgsql.md` — Update examples if any use explicit names +- `docs/src/content/docs/tutorials/build-an-admin-panel.md` — Multiple entity examples with explicit `#[Column('...')]` +- `docs/src/content/docs/tutorials/build-a-chat.md` — Entity with `#[Column('created_at')]` +- `docs/src/content/docs/tutorials/custom-module.md` — Entity with `#[Column('user_id')]`, `#[Column('viewed_at')]` +- `docs/src/content/docs/tutorials/build-a-blog.md` — Entity with `#[Column('published_at')]`, `#[Column('created_at')]` +- `docs/src/content/docs/tutorials/build-a-rest-api.md` — Entity with `#[Column('author_email')]`, `#[Column('created_at')]`, `#[Column('updated_at')]` +- `packages/database/README.md` — Quick example entity + +**Key changes per file:** +- Remove redundant `#[Column('snake_name')]` where property auto-converts correctly +- Keep explicit names that DON'T match auto-conversion (e.g., `#[Column('email_address')]` on `$email`, `#[Column('author_email')]` on `$authorEmail`) +- Add a note about the auto-conversion convention where Column attributes are documented +- Mention that `name:` override is available for custom mappings + +**Example of what to check:** `#[Column('author_email')]` on property `$authorEmail` — `authorEmail` auto-converts to `author_email`, so this IS redundant. But `#[Column(name: 'author_id', references: 'users.id')]` on `$authorId` — name is redundant, keep `references`. + +## Requirements (Test Descriptions) +- [ ] `it removes redundant explicit Column names from database guide examples` +- [ ] `it removes redundant explicit Column names from tutorial examples` +- [ ] `it documents auto-conversion convention in database package docs` +- [ ] `it preserves explicit name overrides that differ from auto-conversion` +- [ ] `it updates README quick example if it has redundant names` + +## Acceptance Criteria +- All doc entity examples use bare `#[Column]` where auto-conversion suffices +- Database package docs explain: property names auto-convert to snake_case, `name:` overrides +- Tutorial examples updated consistently +- No doc examples show redundant explicit names +- Build docs site to verify no broken formatting (if possible) + +## Implementation Notes +(Left blank - filled in by programmer during implementation) diff --git a/.claude/plans/camel-to-snake-columns/_devils_advocate.md b/.claude/plans/camel-to-snake-columns/_devils_advocate.md new file mode 100644 index 00000000..7d8ecd0d --- /dev/null +++ b/.claude/plans/camel-to-snake-columns/_devils_advocate.md @@ -0,0 +1,85 @@ +# Devil's Advocate Review: camel-to-snake-columns + +## Critical (Must fix before building) + +### C1. Task 005 missing authentication-token and media test updates (will break tests) + +Task 005 lists updating admin-auth entity tests that check `$columnAttr->name` via reflection, but completely omits that `packages/authentication-token/tests/Entity/PersonalAccessTokenTest.php` and `packages/media/tests/Entity/MediaTest.php` have the exact same pattern. After removing explicit `name:` parameters from those entities, these tests will fail because `$column->name` becomes `null`. + +**authentication-token test assertions that will break (6 assertions):** +- Line 38: `$tokenableTypeColumn->name->toBe('tokenable_type')` +- Line 45: `$tokenableIdColumn->name->toBe('tokenable_id')` +- Line 57: `$tokenHashColumn->name->toBe('token_hash')` +- Line 72: `$lastUsedAtColumn->name->toBe('last_used_at')` +- Line 79: `$expiresAtColumn->name->toBe('expires_at')` +- Line 86: `$createdAtColumn->name->toBe('created_at')` + +**media test assertions that will break (4 assertions):** +- Line 45: `$originalFilenameColumn->name->toBe('original_filename')` +- Line 53: `$mimeTypeColumn->name->toBe('mime_type')` +- Line 88: `$createdAtColumn->name->toBe('created_at')` +- Line 96: `$updatedAtColumn->name->toBe('updated_at')` + +**Fix:** Add these test files to task 005's scope with explicit instructions to update all `->name` assertions to `->toBeNull()` or remove them. + +### C2. Task 004 massively underestimates scope (23 occurrences of `isActive` in RepositoryTest.php) + +Task 004 says "search the file for `isActive` and update all column-name references to `is_active`" but does not convey the actual scope: there are 23 occurrences of `isActive` in `RepositoryTest.php` across mock storage arrays, SQL string assertions, findBy criteria, and comments. Many of these are in mock `ConnectionInterface` implementations with hardcoded column-name keys in return arrays. The worker needs to understand that ALL mock data arrays using `'isActive'` as a key need updating to `'is_active'`, plus SQL string matches like `str_contains($setClause, 'isActive')`. + +The task also doesn't mention that comment on line 188 (`// - 'isActive' maps to 'isActive' column (no explicit name, uses property name)`) needs updating. + +**Fix:** Add explicit list of affected areas in task 004 context. + +### C3. Task 005 is too large for a single worker (7 entity files + 5 test files across 5 packages) + +Task 005 spans 7 production entity files across 5 packages (admin-auth, media, webhook, authentication-token, database) plus test files in admin-auth, authentication-token, media, and database. This is a lot of files with subtle per-file differences. A single TDD worker would struggle with the breadth. + +**Fix:** Split task 005 into two tasks: one for production entity cleanup (mechanical removal of redundant `name:` parameters) and one for test updates (which requires understanding what each test asserts about `$column->name`). + +## Important (Should fix before building) + +### I1. Task 001 "uses Column attribute name when specified" test becomes a no-op + +The existing test at `EntityMetadataFactoryTest.php` line 172 uses `#[Column(name: 'user_id')]` on `$userId`. After the change, `userId` auto-converts to `user_id`, making the explicit name redundant -- the test passes whether or not the override mechanism works. Task 005 mentions this needs reworking, but this should be done in task 001 since task 001 is the one that needs to verify the override actually functions. + +**Fix:** Move the requirement to fix this test from task 005 to task 001. Task 001 should modify this test to use a genuinely custom name (e.g., `#[Column(name: 'author')]` on `$userId`) so it validates the override mechanism. + +### I2. Task 003 line references may be inaccurate + +Task 003 references specific line numbers (230, 237, 267, 289) in `EntityToMigrationWorkflowTest.php`. Verified against actual file: line 230 contains `->toContain('isActive')` and line 237 contains `->toContain('authorId')`. These are correct. However, line 267 contains `new SchemaColumn(name: 'isActive', type: 'BOOLEAN')` which is correct, and line 289 contains `->toContain('isActive')` which is correct. Good. + +However, the task misses an additional reference: the `SchemaColumn` construction and the `$addedColumnNames` assertion -- these are in the "detects and generates migrations for entity changes" test which constructs `SchemaTable` objects manually. Those manual `SchemaColumn` constructions with `name: 'isActive'` also need updating to `name: 'is_active'` for test consistency (they represent the "entity-defined schema" which would now produce snake_case). + +**Fix:** Add explicit note to task 003 about updating ALL manually constructed `SchemaColumn`/`SchemaTable` objects that represent entity-derived schema. + +### I3. WebhookAttempt has constructor-promoted properties with #[Column] + +`WebhookAttempt` has `#[Column(name: 'webhook_url')]` and `#[Column(name: 'attempt_number')]` on constructor-promoted properties (lines 30, 34). Task 005 correctly identifies these need cleanup, but the worker should verify that `EntityMetadataFactory::parse()` actually processes constructor-promoted properties via `$reflection->getProperties(ReflectionProperty::IS_PUBLIC)`. PHP's ReflectionClass does return promoted properties from `getProperties()`, so this should work, but it is worth noting. + +### I4. Task 003 and 004 could cause confusion about RepositoryCrudTest vs RepositoryTest scope + +Task 003 covers `RepositoryCrudTest.php` (feature test) while task 004 covers `RepositoryTest.php` (unit test). Both have `isActive`/`isAvailable` references. The task descriptions are clear about which files belong where, but a worker could accidentally edit the wrong file. No change needed, just noting the risk. + +## Minor (Nice to address) + +### M1. Notification-database entity has a pre-existing inconsistency that this change fixes + +`DatabaseNotification` has bare `#[Column]` on `$notifiableType`, `$notifiableId`, `$readAt`, `$createdAt`. Currently these produce camelCase column names (`notifiableType`, etc.), but the `DatabaseNotificationRepository` uses handwritten SQL with snake_case column names (`notifiable_type`, etc.) and manual hydration mapping `$row['notifiable_type']` to `$notification->notifiableType`. This is a pre-existing bug/inconsistency. After this change, the metadata will align with the SQL. No action needed since the repository doesn't use EntityMetadataFactory for queries, but worth noting that this change accidentally fixes a latent issue. + +### M2. ColumnAttributeTest fixtures have camelCase properties (userId) but tests the attribute directly + +`packages/database/tests/Attributes/ColumnAttributeTest.php` has fixture entities like `ForeignKeyReferenceTestEntity` with `$userId`. These test the Column attribute class directly via reflection, not the factory, so they won't be affected. No changes needed. + +### M3. Documentation task (006) should note that `#[Column('author_email')]` on `$authorEmail` IS redundant + +Task 006 says "Keep explicit names that DON'T match auto-conversion (e.g., `#[Column('author_email')]` on `$authorEmail`)" but then correctly notes in the last paragraph that `authorEmail` auto-converts to `author_email`, so it IS redundant. The earlier statement is misleading. + +## Questions for the Team + +### Q1. Should the `camelToSnakeCase` method be a standalone utility or private to EntityMetadataFactory? + +The plan puts it as a private method on `EntityMetadataFactory`. If other parts of the framework ever need the same conversion (e.g., for table name conventions), it would need to be duplicated. A utility class in `Marko\Database\Support\Str` or similar could be reusable. However, YAGNI applies -- making it private is fine for now. + +### Q2. What about the `$primaryKey` field in EntityMetadata? + +Line 90 of `EntityMetadataFactory::parse()` sets `$primaryKey = $propertyName` (not column name). This is the PHP property name, used for things like `EntityMetadata::getPrimaryKeyProperty()`. This is correct -- the primary key identifier should be the property name. But the plan should verify no code path uses `$metadata->primaryKey` as a column name. diff --git a/.claude/plans/camel-to-snake-columns/_plan.md b/.claude/plans/camel-to-snake-columns/_plan.md new file mode 100644 index 00000000..05c0cbac --- /dev/null +++ b/.claude/plans/camel-to-snake-columns/_plan.md @@ -0,0 +1,60 @@ +# Plan: Auto-convert camelCase Property Names to snake_case Column Names + +## Created +2026-04-14 + +## Status +completed + +## Objective +When a `#[Column]` attribute has no explicit `name:` parameter, automatically convert the PHP property name from camelCase to snake_case for the database column name. Remove all now-redundant explicit `name:` parameters across the codebase. + +## Related Issues +none + +## Scope + +### In Scope +- Add `camelToSnakeCase()` private method to `EntityMetadataFactory` +- Update `EntityMetadataFactory::parse()` to auto-convert property names +- Remove all redundant explicit `#[Column(name: '...')]` parameters across all packages +- Update all test fixtures and assertions to reflect snake_case column names +- Update documentation with new convention +- Handle edge cases: consecutive caps (`$userID` -> `user_id`), acronyms (`$HTMLParser` -> `html_parser`), single-word properties (no change) + +### Out of Scope +- Migration tooling for existing databases with camelCase columns +- Configurable naming strategy (always snake_case) +- Changes to `#[Table]` naming conventions +- Changing property names themselves (only column name mapping changes) + +## Success Criteria +- [ ] `EntityMetadataFactory` auto-converts camelCase property names to snake_case column names +- [ ] Explicit `#[Column(name: '...')]` override still takes priority +- [ ] All redundant explicit column names removed from production entities +- [ ] All tests updated and passing (4639+ tests) +- [ ] Documentation updated with auto-conversion convention +- [ ] Edge cases handled: `$id` -> `id`, `$userId` -> `user_id`, `$HTMLParser` -> `html_parser`, `$userID` -> `user_id` + +## Task Overview +| Task | Description | Depends On | Status | +|------|-------------|------------|--------| +| 001 | Add camelToSnakeCase, update EntityMetadataFactory, fix override test | - | completed | +| 002 | Update database package unit tests | 001 | completed | +| 003 | Update database package feature tests | 001 | completed | +| 004 | Update Repository tests (23 isActive refs + mock data arrays) | 001 | completed | +| 005 | Remove redundant names from production entities + update entity tests (admin-auth, media, auth-token, webhook) | 001 | completed | +| 006 | Update documentation | 001, 005 | completed | + +## Architecture Notes +- The core change is a single line in `EntityMetadataFactory::parse()` (line 68): `$columnName = $columnAttr->name ?? $propertyName` becomes `$columnName = $columnAttr->name ?? $this->camelToSnakeCase($propertyName)` +- All downstream consumers (SchemaBuilder, DiffCalculator, SQL generators, Repository, EntityHydrator) read column names from `PropertyMetadata::$columnName` / `ColumnMetadata::$name`, so they automatically get snake_case — no code changes needed in those classes +- FK names generated by `SchemaBuilder::buildForeignKeys()` use `$column->name`, so FK names like `fk_posts_userId` will become `fk_posts_user_id` (more consistent) +- The `#[Column]` attribute's first positional arg is `name`, so `#[Column('foo')]` is equivalent to `#[Column(name: 'foo')]` +- Admin-auth, authentication-token, and media entity tests check Column attribute `name` via reflection — these all need updating when explicit names are removed +- `Repository::findBy()` accepts property names as criteria keys and maps to column names via `$metadata->getPropertyToColumnMap()`, so `findBy(['isActive' => true])` calls stay as-is but generated SQL changes from `isActive = ?` to `is_active = ?` + +## Risks & Mitigations +- **Breaking change for existing databases**: Mitigated by documenting that explicit `name:` override still works as an escape hatch +- **Edge cases in conversion algorithm**: Mitigated by comprehensive test coverage of edge cases (consecutive caps, acronyms, single-word names) +- **Hidden test failures from column name changes**: Mitigated by running full test suite (`./vendor/bin/pest --parallel`) after each task diff --git a/docs/src/content/docs/guides/database.md b/docs/src/content/docs/guides/database.md index 5838016d..7efaddfb 100644 --- a/docs/src/content/docs/guides/database.md +++ b/docs/src/content/docs/guides/database.md @@ -64,7 +64,7 @@ class Post extends Entity #[Column] public bool $published = false; - #[Column('created_at')] + #[Column] public ?string $createdAt = null; } ``` diff --git a/docs/src/content/docs/packages/database.md b/docs/src/content/docs/packages/database.md index 0b70b1ac..5de7c9d1 100644 --- a/docs/src/content/docs/packages/database.md +++ b/docs/src/content/docs/packages/database.md @@ -53,13 +53,13 @@ class Post extends Entity #[Column(default: 'draft')] public PostStatus $status = PostStatus::Draft; - #[Column(name: 'author_id', references: 'users.id', onDelete: 'cascade')] + #[Column(references: 'users.id', onDelete: 'cascade')] public int $authorId; - #[Column(name: 'created_at', default: 'CURRENT_TIMESTAMP')] + #[Column(default: 'CURRENT_TIMESTAMP')] public DateTimeImmutable $createdAt; - #[Column(name: 'updated_at')] + #[Column] public ?DateTimeImmutable $updatedAt = null; } ``` @@ -72,6 +72,8 @@ class Post extends Entity | `#[Column]` | Column configuration (name, primaryKey, autoIncrement, length, type, unique, default, references, onDelete, onUpdate) | | `#[Index]` | Composite indexes | +Property names are automatically converted from camelCase to snake_case for column names. For example, `$createdAt` maps to the `created_at` column. Use the `name` parameter to override this: `#[Column(name: 'custom_column')]`. + ### Type Inference Rules Marko infers database types from PHP types: diff --git a/docs/src/content/docs/tutorials/build-a-blog.md b/docs/src/content/docs/tutorials/build-a-blog.md index 41fca536..8dbc267d 100644 --- a/docs/src/content/docs/tutorials/build-a-blog.md +++ b/docs/src/content/docs/tutorials/build-a-blog.md @@ -75,10 +75,10 @@ class Post extends Entity #[Column] public bool $published = false; - #[Column('published_at')] + #[Column] public ?string $publishedAt = null; - #[Column('created_at')] + #[Column] public ?string $createdAt = null; } ``` diff --git a/docs/src/content/docs/tutorials/build-a-chat.md b/docs/src/content/docs/tutorials/build-a-chat.md index fa038184..0a6a78a8 100644 --- a/docs/src/content/docs/tutorials/build-a-chat.md +++ b/docs/src/content/docs/tutorials/build-a-chat.md @@ -79,7 +79,7 @@ class Message extends Entity #[Column(type: 'TEXT')] public string $body; - #[Column('created_at')] + #[Column] public ?string $createdAt = null; } ``` diff --git a/docs/src/content/docs/tutorials/build-a-rest-api.md b/docs/src/content/docs/tutorials/build-a-rest-api.md index b035c7b1..fa1ab44b 100644 --- a/docs/src/content/docs/tutorials/build-a-rest-api.md +++ b/docs/src/content/docs/tutorials/build-a-rest-api.md @@ -57,13 +57,13 @@ class Article extends Entity #[Column(type: 'TEXT')] public string $body; - #[Column('author_email')] + #[Column] public string $authorEmail; - #[Column('created_at')] + #[Column] public ?string $createdAt = null; - #[Column('updated_at')] + #[Column] public ?string $updatedAt = null; } ``` diff --git a/docs/src/content/docs/tutorials/build-an-admin-panel.md b/docs/src/content/docs/tutorials/build-an-admin-panel.md index 6d455acc..ce478718 100644 --- a/docs/src/content/docs/tutorials/build-an-admin-panel.md +++ b/docs/src/content/docs/tutorials/build-an-admin-panel.md @@ -132,16 +132,16 @@ class AdminUser extends Entity implements AdminUserInterface #[Column] public string $name; - #[Column('remember_token')] + #[Column] public ?string $rememberToken = null; - #[Column('is_active', default: '1')] + #[Column(default: '1')] public string $isActive = '1'; - #[Column('created_at')] + #[Column] public ?string $createdAt = null; - #[Column('updated_at')] + #[Column] public ?string $updatedAt = null; // ... authentication and role/permission methods @@ -176,13 +176,13 @@ class Role extends Entity implements RoleInterface #[Column(type: 'TEXT')] public ?string $description = null; - #[Column('is_super_admin', default: '0')] + #[Column(default: '0')] public string $isSuperAdmin = '0'; - #[Column('created_at')] + #[Column] public ?string $createdAt = null; - #[Column('updated_at')] + #[Column] public ?string $updatedAt = null; // ... @@ -207,10 +207,10 @@ use Marko\Database\Entity\Entity; #[Index('idx_role_permissions_unique', ['role_id', 'permission_id'], unique: true)] class RolePermission extends Entity implements RolePermissionInterface { - #[Column('role_id', references: 'roles.id', onDelete: 'CASCADE')] + #[Column(references: 'roles.id', onDelete: 'CASCADE')] public int $roleId; - #[Column('permission_id', references: 'permissions.id', onDelete: 'CASCADE')] + #[Column(references: 'permissions.id', onDelete: 'CASCADE')] public int $permissionId; } ``` diff --git a/docs/src/content/docs/tutorials/custom-module.md b/docs/src/content/docs/tutorials/custom-module.md index 300b002b..61b7d138 100644 --- a/docs/src/content/docs/tutorials/custom-module.md +++ b/docs/src/content/docs/tutorials/custom-module.md @@ -67,10 +67,10 @@ class PageView extends Entity #[Column] public string $path; - #[Column('user_id')] + #[Column] public ?string $userId = null; - #[Column('viewed_at')] + #[Column] public string $viewedAt; public function __construct( diff --git a/packages/admin-auth/src/Entity/AdminUser.php b/packages/admin-auth/src/Entity/AdminUser.php index 736a00b7..f0b763fc 100644 --- a/packages/admin-auth/src/Entity/AdminUser.php +++ b/packages/admin-auth/src/Entity/AdminUser.php @@ -23,16 +23,16 @@ class AdminUser extends Entity implements AdminUserInterface #[Column] public string $name; - #[Column('remember_token')] + #[Column] public ?string $rememberToken = null; - #[Column('is_active', default: '1')] + #[Column(default: '1')] public string $isActive = '1'; - #[Column('created_at')] + #[Column] public ?string $createdAt = null; - #[Column('updated_at')] + #[Column] public ?string $updatedAt = null; public function getEmail(): string diff --git a/packages/admin-auth/src/Entity/Permission.php b/packages/admin-auth/src/Entity/Permission.php index 0f5e2e35..5a6ced5d 100644 --- a/packages/admin-auth/src/Entity/Permission.php +++ b/packages/admin-auth/src/Entity/Permission.php @@ -25,7 +25,7 @@ class Permission extends Entity implements PermissionInterface #[Column] public string $group; - #[Column('created_at')] + #[Column] public ?string $createdAt = null; public function getId(): ?int diff --git a/packages/admin-auth/src/Entity/Role.php b/packages/admin-auth/src/Entity/Role.php index 78df5758..50a6ed2d 100644 --- a/packages/admin-auth/src/Entity/Role.php +++ b/packages/admin-auth/src/Entity/Role.php @@ -25,13 +25,13 @@ class Role extends Entity implements RoleInterface #[Column(type: 'TEXT')] public ?string $description = null; - #[Column('is_super_admin', default: '0')] + #[Column(default: '0')] public string $isSuperAdmin = '0'; - #[Column('created_at')] + #[Column] public ?string $createdAt = null; - #[Column('updated_at')] + #[Column] public ?string $updatedAt = null; public function getId(): ?int diff --git a/packages/admin-auth/src/Entity/RolePermission.php b/packages/admin-auth/src/Entity/RolePermission.php index d1011872..6df9f78f 100644 --- a/packages/admin-auth/src/Entity/RolePermission.php +++ b/packages/admin-auth/src/Entity/RolePermission.php @@ -13,10 +13,10 @@ #[Index('idx_role_permissions_unique', ['role_id', 'permission_id'], unique: true)] class RolePermission extends Entity implements RolePermissionInterface { - #[Column('role_id', references: 'roles.id', onDelete: 'CASCADE')] + #[Column(references: 'roles.id', onDelete: 'CASCADE')] public int $roleId; - #[Column('permission_id', references: 'permissions.id', onDelete: 'CASCADE')] + #[Column(references: 'permissions.id', onDelete: 'CASCADE')] public int $permissionId; public function getRoleId(): int diff --git a/packages/admin-auth/tests/Unit/Entity/AdminUserTest.php b/packages/admin-auth/tests/Unit/Entity/AdminUserTest.php index 3d91cc5a..6f6458e1 100644 --- a/packages/admin-auth/tests/Unit/Entity/AdminUserTest.php +++ b/packages/admin-auth/tests/Unit/Entity/AdminUserTest.php @@ -26,6 +26,7 @@ expect($tableAttributes)->toHaveCount(1); $tableAttribute = $tableAttributes[0]->newInstance(); + expect($tableAttribute->name)->toBe('admin_users'); }); @@ -64,12 +65,12 @@ $rememberTokenProp = $reflection->getProperty('rememberToken'); $rememberTokenColumn = $rememberTokenProp->getAttributes(Column::class)[0]->newInstance(); - expect($rememberTokenColumn->name)->toBe('remember_token') + expect($rememberTokenColumn->name)->toBeNull() ->and($rememberTokenProp->getType()->allowsNull())->toBeTrue(); $isActiveProp = $reflection->getProperty('isActive'); $isActiveColumn = $isActiveProp->getAttributes(Column::class)[0]->newInstance(); - expect($isActiveColumn->name)->toBe('is_active') + expect($isActiveColumn->name)->toBeNull() ->and($isActiveColumn->default)->toBe('1'); }); diff --git a/packages/admin-auth/tests/Unit/Entity/PermissionTest.php b/packages/admin-auth/tests/Unit/Entity/PermissionTest.php index 5f02efe6..90d36760 100644 --- a/packages/admin-auth/tests/Unit/Entity/PermissionTest.php +++ b/packages/admin-auth/tests/Unit/Entity/PermissionTest.php @@ -114,23 +114,21 @@ expect($attributes)->toHaveCount(1); $columnAttribute = $attributes[0]->newInstance(); - expect($columnAttribute->name)->toBe('created_at'); + expect($columnAttribute->name)->toBeNull(); }); it('uses nullable types for optional fields appropriately', function (): void { $reflection = new ReflectionClass(Permission::class); $idProperty = $reflection->getProperty('id'); - expect($idProperty->getType()->allowsNull())->toBeTrue(); - $createdAtProperty = $reflection->getProperty('createdAt'); - expect($createdAtProperty->getType()->allowsNull())->toBeTrue(); - $keyProperty = $reflection->getProperty('key'); $labelProperty = $reflection->getProperty('label'); $groupProperty = $reflection->getProperty('group'); - expect($keyProperty->getType()->allowsNull())->toBeFalse() + expect($idProperty->getType()->allowsNull())->toBeTrue() + ->and($createdAtProperty->getType()->allowsNull())->toBeTrue() + ->and($keyProperty->getType()->allowsNull())->toBeFalse() ->and($labelProperty->getType()->allowsNull())->toBeFalse() ->and($groupProperty->getType()->allowsNull())->toBeFalse(); }); diff --git a/packages/admin-auth/tests/Unit/Entity/RolePermissionTest.php b/packages/admin-auth/tests/Unit/Entity/RolePermissionTest.php index 8538948e..e7fd6c32 100644 --- a/packages/admin-auth/tests/Unit/Entity/RolePermissionTest.php +++ b/packages/admin-auth/tests/Unit/Entity/RolePermissionTest.php @@ -39,7 +39,7 @@ expect($attributes)->toHaveCount(1); $columnAttribute = $attributes[0]->newInstance(); - expect($columnAttribute->name)->toBe('role_id') + expect($columnAttribute->name)->toBeNull() ->and($columnAttribute->references)->toBe('roles.id') ->and($columnAttribute->onDelete)->toBe('CASCADE'); }); @@ -52,7 +52,7 @@ expect($attributes)->toHaveCount(1); $columnAttribute = $attributes[0]->newInstance(); - expect($columnAttribute->name)->toBe('permission_id') + expect($columnAttribute->name)->toBeNull() ->and($columnAttribute->references)->toBe('permissions.id') ->and($columnAttribute->onDelete)->toBe('CASCADE'); }); @@ -63,19 +63,16 @@ expect($indexAttributes)->not->toBeEmpty(); - $foundUniqueIndex = false; - - foreach ($indexAttributes as $attribute) { - $index = $attribute->newInstance(); - if ($index->unique && in_array('role_id', $index->columns, true) && in_array( - 'permission_id', - $index->columns, - true, - )) { - $foundUniqueIndex = true; - break; - } - } + $foundUniqueIndex = array_any( + $indexAttributes, + function ($attribute) { + $index = $attribute->newInstance(); + + return $index->unique + && in_array('role_id', $index->columns, true) + && in_array('permission_id', $index->columns, true); + }, + ); expect($foundUniqueIndex)->toBeTrue(); }); diff --git a/packages/admin-auth/tests/Unit/Entity/RoleTest.php b/packages/admin-auth/tests/Unit/Entity/RoleTest.php index e724b9ae..afa9d5ad 100644 --- a/packages/admin-auth/tests/Unit/Entity/RoleTest.php +++ b/packages/admin-auth/tests/Unit/Entity/RoleTest.php @@ -121,7 +121,7 @@ expect($attributes)->toHaveCount(1); $columnAttribute = $attributes[0]->newInstance(); - expect($columnAttribute->name)->toBe('is_super_admin') + expect($columnAttribute->name)->toBeNull() ->and($columnAttribute->default)->toBe('0'); }); @@ -148,7 +148,7 @@ expect($attributes)->toHaveCount(1); $columnAttribute = $attributes[0]->newInstance(); - expect($columnAttribute->name)->toBe('created_at'); + expect($columnAttribute->name)->toBeNull(); }); it('has updated_at timestamp', function (): void { @@ -162,28 +162,24 @@ expect($attributes)->toHaveCount(1); $columnAttribute = $attributes[0]->newInstance(); - expect($columnAttribute->name)->toBe('updated_at'); + expect($columnAttribute->name)->toBeNull(); }); it('uses nullable types for optional fields appropriately', function (): void { $reflection = new ReflectionClass(Role::class); $idProperty = $reflection->getProperty('id'); - expect($idProperty->getType()->allowsNull())->toBeTrue(); - $createdAtProperty = $reflection->getProperty('createdAt'); - expect($createdAtProperty->getType()->allowsNull())->toBeTrue(); - $updatedAtProperty = $reflection->getProperty('updatedAt'); - expect($updatedAtProperty->getType()->allowsNull())->toBeTrue(); - $descriptionProperty = $reflection->getProperty('description'); - expect($descriptionProperty->getType()->allowsNull())->toBeTrue(); - $nameProperty = $reflection->getProperty('name'); $slugProperty = $reflection->getProperty('slug'); - expect($nameProperty->getType()->allowsNull())->toBeFalse() + expect($idProperty->getType()->allowsNull())->toBeTrue() + ->and($createdAtProperty->getType()->allowsNull())->toBeTrue() + ->and($updatedAtProperty->getType()->allowsNull())->toBeTrue() + ->and($descriptionProperty->getType()->allowsNull())->toBeTrue() + ->and($nameProperty->getType()->allowsNull())->toBeFalse() ->and($slugProperty->getType()->allowsNull())->toBeFalse(); }); diff --git a/packages/authentication-token/src/Entity/PersonalAccessToken.php b/packages/authentication-token/src/Entity/PersonalAccessToken.php index dfb526a5..ceebba4f 100644 --- a/packages/authentication-token/src/Entity/PersonalAccessToken.php +++ b/packages/authentication-token/src/Entity/PersonalAccessToken.php @@ -14,28 +14,28 @@ class PersonalAccessToken extends Entity #[Column(primaryKey: true, autoIncrement: true)] public ?int $id = null; - #[Column('tokenable_type')] + #[Column] public string $tokenableType = ''; - #[Column('tokenable_id')] + #[Column] public int $tokenableId = 0; #[Column] public string $name = ''; - #[Column('token_hash', length: 64)] + #[Column(length: 64)] public string $tokenHash = ''; #[Column(type: 'text')] public ?string $abilities = null; /** @noinspection PhpUnused */ - #[Column('last_used_at')] + #[Column] public ?string $lastUsedAt = null; - #[Column('expires_at')] + #[Column] public ?string $expiresAt = null; - #[Column('created_at')] + #[Column] public ?string $createdAt = null; } diff --git a/packages/authentication-token/tests/Entity/PersonalAccessTokenTest.php b/packages/authentication-token/tests/Entity/PersonalAccessTokenTest.php index b21e0191..9f31e839 100644 --- a/packages/authentication-token/tests/Entity/PersonalAccessTokenTest.php +++ b/packages/authentication-token/tests/Entity/PersonalAccessTokenTest.php @@ -35,14 +35,14 @@ $tokenableTypeColumns = $tokenableTypeProperty->getAttributes(Column::class); $tokenableTypeColumn = $tokenableTypeColumns[0]->newInstance(); expect($tokenableTypeColumns)->toHaveCount(1) - ->and($tokenableTypeColumn->name)->toBe('tokenable_type'); + ->and($tokenableTypeColumn->name)->toBeNull(); // Has tokenable_id column $tokenableIdProperty = $reflection->getProperty('tokenableId'); $tokenableIdColumns = $tokenableIdProperty->getAttributes(Column::class); $tokenableIdColumn = $tokenableIdColumns[0]->newInstance(); expect($tokenableIdColumns)->toHaveCount(1) - ->and($tokenableIdColumn->name)->toBe('tokenable_id'); + ->and($tokenableIdColumn->name)->toBeNull(); // Has name column $nameProperty = $reflection->getProperty('name'); @@ -54,7 +54,7 @@ $tokenHashColumns = $tokenHashProperty->getAttributes(Column::class); $tokenHashColumn = $tokenHashColumns[0]->newInstance(); expect($tokenHashColumns)->toHaveCount(1) - ->and($tokenHashColumn->name)->toBe('token_hash') + ->and($tokenHashColumn->name)->toBeNull() ->and($tokenHashColumn->length)->toBe(64); // Has abilities column (text, nullable) @@ -69,19 +69,19 @@ $lastUsedAtColumns = $lastUsedAtProperty->getAttributes(Column::class); $lastUsedAtColumn = $lastUsedAtColumns[0]->newInstance(); expect($lastUsedAtColumns)->toHaveCount(1) - ->and($lastUsedAtColumn->name)->toBe('last_used_at'); + ->and($lastUsedAtColumn->name)->toBeNull(); // Has expires_at column (nullable) $expiresAtProperty = $reflection->getProperty('expiresAt'); $expiresAtColumns = $expiresAtProperty->getAttributes(Column::class); $expiresAtColumn = $expiresAtColumns[0]->newInstance(); expect($expiresAtColumns)->toHaveCount(1) - ->and($expiresAtColumn->name)->toBe('expires_at'); + ->and($expiresAtColumn->name)->toBeNull(); // Has created_at column (nullable) $createdAtProperty = $reflection->getProperty('createdAt'); $createdAtColumns = $createdAtProperty->getAttributes(Column::class); $createdAtColumn = $createdAtColumns[0]->newInstance(); expect($createdAtColumns)->toHaveCount(1) - ->and($createdAtColumn->name)->toBe('created_at'); + ->and($createdAtColumn->name)->toBeNull(); }); diff --git a/packages/database/README.md b/packages/database/README.md index c4e72234..ca34051f 100644 --- a/packages/database/README.md +++ b/packages/database/README.md @@ -31,7 +31,7 @@ class Post extends Entity #[Column(length: 255, unique: true)] public string $slug; - #[Column(type: 'text', nullable: true, default: null)] + #[Column(type: 'text', nullable: true)] public ?string $content = null; #[Column(nullable: true, default: 'draft')] @@ -58,7 +58,7 @@ use Marko\Database\Repository\Repository; class PostRepository extends Repository { - protected string $entity = Post::class; + protected const string ENTITY_CLASS = Post::class; } ``` diff --git a/packages/database/src/Entity/EntityMetadataFactory.php b/packages/database/src/Entity/EntityMetadataFactory.php index 8f629a8a..913829fb 100644 --- a/packages/database/src/Entity/EntityMetadataFactory.php +++ b/packages/database/src/Entity/EntityMetadataFactory.php @@ -38,6 +38,8 @@ class EntityMetadataFactory * Parse an entity class and return its metadata. * * @param class-string $entityClass + * + * @throws EntityException */ public function parse( string $entityClass, @@ -50,7 +52,7 @@ public function parse( $this->validateEntity($reflection, $entityClass); - $tableName = $this->extractTableName($reflection, $entityClass); + $tableName = $this->extractTableName($reflection); $columns = []; $indexes = []; $properties = []; @@ -65,7 +67,7 @@ public function parse( $columnAttr = $columnAttributes[0]->newInstance(); $propertyName = $property->getName(); - $columnName = $columnAttr->name ?? $propertyName; + $columnName = $columnAttr->name ?? $this->camelToSnakeCase($propertyName); $type = $property->getType(); if (!$type instanceof ReflectionNamedType) { @@ -163,6 +165,8 @@ public function clearCache(): void * * @param ReflectionClass $reflection * @param class-string $entityClass + * + * @throws EntityException */ private function validateEntity( ReflectionClass $reflection, @@ -182,17 +186,27 @@ private function validateEntity( * Extract the table name from the #[Table] attribute. * * @param ReflectionClass $reflection - * @param class-string $entityClass */ private function extractTableName( ReflectionClass $reflection, - string $entityClass, ): string { $tableAttributes = $reflection->getAttributes(Table::class); return $tableAttributes[0]->newInstance()->name; } + /** + * Convert a camelCase property name to snake_case for use as a column name. + */ + private function camelToSnakeCase( + string $name, + ): string { + $result = (string) preg_replace('/([a-z0-9])([A-Z])/', '$1_$2', $name); + $result = (string) preg_replace('/([A-Z]+)([A-Z][a-z])/', '$1_$2', $result); + + return strtolower($result); + } + /** * Infer the database type from a PHP type. */ diff --git a/packages/database/tests/Entity/EntityMetadataFactoryTest.php b/packages/database/tests/Entity/EntityMetadataFactoryTest.php index 2d151272..fe6c337b 100644 --- a/packages/database/tests/Entity/EntityMetadataFactoryTest.php +++ b/packages/database/tests/Entity/EntityMetadataFactoryTest.php @@ -172,7 +172,7 @@ class () extends Entity it('uses Column attribute name when specified', function (): void { $entity = new #[Table('test')] class () extends Entity { - #[Column(name: 'user_id')] + #[Column(name: 'author')] public int $userId; #[Column] @@ -182,7 +182,7 @@ class () extends Entity $metadata = $this->factory->parse($entity::class); expect($metadata->columns[0]->name) - ->toBe('user_id') + ->toBe('author') ->and($metadata->columns[1]->name)->toBe('title'); }); @@ -296,6 +296,98 @@ class UntypedPropertyEntity extends Entity $this->factory->parse($className); })->throws(EntityException::class, 'must have a type declaration'); +it('handles leading uppercase sequences correctly (HTMLParser becomes html_parser)', function (): void { + $entity = new #[Table('records')] class () extends Entity + { + #[Column(primaryKey: true)] + public int $id; + + /** @noinspection PhpUnused - Accessed via reflection metadata */ + #[Column] + public string $HTMLParser; + }; + + $metadata = $this->factory->parse($entity::class); + + expect($metadata->columns[1]->name)->toBe('html_parser'); +}); + +it('handles consecutive uppercase letters correctly (userID becomes user_id)', function (): void { + $entity = new #[Table('records')] class () extends Entity + { + #[Column(primaryKey: true)] + public int $id; + + /** @noinspection PhpUnused - Accessed via reflection metadata */ + #[Column] + public int $userID; + }; + + $metadata = $this->factory->parse($entity::class); + + expect($metadata->columns[1]->name)->toBe('user_id'); +}); + +it('handles single-word property names without change', function (): void { + $entity = new #[Table('users')] class () extends Entity + { + #[Column(primaryKey: true)] + public int $id; + + #[Column] + public string $name; + + #[Column] + public string $email; + }; + + $metadata = $this->factory->parse($entity::class); + + expect($metadata->columns[0]->name) + ->toBe('id') + ->and($metadata->columns[1]->name)->toBe('name') + ->and($metadata->columns[2]->name)->toBe('email'); +}); + +it('preserves explicit Column name override when specified', function (): void { + $entity = new #[Table('posts')] class () extends Entity + { + #[Column(primaryKey: true)] + public int $id; + + #[Column(name: 'author')] + public int $userId; + }; + + $metadata = $this->factory->parse($entity::class); + + expect($metadata->columns[1]->name)->toBe('author'); +}); + +it('converts camelCase property names to snake_case column names automatically', function (): void { + $entity = new #[Table('posts')] class () extends Entity + { + #[Column(primaryKey: true, autoIncrement: true)] + public int $id; + + #[Column] + public int $postId; + + #[Column] + public string $createdAt; + + #[Column] + public bool $isActive; + }; + + $metadata = $this->factory->parse($entity::class); + + expect($metadata->columns[1]->name) + ->toBe('post_id') + ->and($metadata->columns[2]->name)->toBe('created_at') + ->and($metadata->columns[3]->name)->toBe('is_active'); +}); + it('clears cached metadata', function (): void { $entity = new #[Table('test')] class () extends Entity { diff --git a/packages/database/tests/Entity/SchemaBuilderTest.php b/packages/database/tests/Entity/SchemaBuilderTest.php index d535faa3..0b9df871 100644 --- a/packages/database/tests/Entity/SchemaBuilderTest.php +++ b/packages/database/tests/Entity/SchemaBuilderTest.php @@ -150,8 +150,8 @@ class () extends Entity expect($table->foreignKeys) ->toHaveCount(1) - ->and($table->foreignKeys[0]->name)->toBe('fk_posts_userId') - ->and($table->foreignKeys[0]->columns)->toBe(['userId']) + ->and($table->foreignKeys[0]->name)->toBe('fk_posts_user_id') + ->and($table->foreignKeys[0]->columns)->toBe(['user_id']) ->and($table->foreignKeys[0]->referencedTable)->toBe('users') ->and($table->foreignKeys[0]->referencedColumns)->toBe(['id']) ->and($table->foreignKeys[0]->onDelete)->toBe('CASCADE') diff --git a/packages/database/tests/Feature/EntityToMigrationWorkflowTest.php b/packages/database/tests/Feature/EntityToMigrationWorkflowTest.php index fa5287d4..abda7241 100644 --- a/packages/database/tests/Feature/EntityToMigrationWorkflowTest.php +++ b/packages/database/tests/Feature/EntityToMigrationWorkflowTest.php @@ -17,6 +17,7 @@ use Marko\Database\Migration\MigrationGenerator; use Marko\Database\Schema\Column as SchemaColumn; use Marko\Database\Schema\ForeignKey; +use Marko\Database\Schema\Index as SchemaIndex; use Marko\Database\Schema\Table as SchemaTable; // Test entities for the workflow tests @@ -147,7 +148,7 @@ public function generateModifyColumn( public function generateAddIndex( string $table, - \Marko\Database\Schema\Index $index, + SchemaIndex $index, ): string { return "CREATE INDEX $index->name ON $table"; } @@ -227,14 +228,14 @@ public function generateDropForeignKey( ->toContain('id') ->toContain('name') ->toContain('email') - ->toContain('isActive') + ->toContain('is_active') ->and($postTable->name)->toBe('workflow_posts') ->and($postTable->columns)->toHaveCount(4) ->and(array_map(fn ($col) => $col->name, $postTable->columns)) ->toContain('id') ->toContain('title') ->toContain('content') - ->toContain('authorId'); + ->toContain('author_id'); // Verify primary key detection $idColumn = array_filter($userTable->columns, fn ($col) => $col->name === 'id'); @@ -264,7 +265,7 @@ public function generateDropForeignKey( new SchemaColumn(name: 'id', type: 'INT', primaryKey: true, autoIncrement: true), new SchemaColumn(name: 'name', type: 'VARCHAR', length: 255), new SchemaColumn(name: 'email', type: 'VARCHAR', length: 255), - new SchemaColumn(name: 'isActive', type: 'BOOLEAN'), + new SchemaColumn(name: 'is_active', type: 'BOOLEAN'), ], indexes: [], ); @@ -286,7 +287,7 @@ public function generateDropForeignKey( $addedColumnNames = array_map(fn ($col) => $col->name, $tableDiff->columnsToAdd); expect($addedColumnNames) ->toContain('email') - ->toContain('isActive'); + ->toContain('is_active'); }); it('detects dropped columns in entity changes', function (): void { diff --git a/packages/database/tests/Feature/RepositoryCrudTest.php b/packages/database/tests/Feature/RepositoryCrudTest.php index 6fb41fa0..e6c3bd24 100644 --- a/packages/database/tests/Feature/RepositoryCrudTest.php +++ b/packages/database/tests/Feature/RepositoryCrudTest.php @@ -104,7 +104,7 @@ public function execute( 'name' => $bindings[0] ?? '', 'price' => $bindings[1] ?? 0.0, 'stock' => $bindings[2] ?? 0, - 'isAvailable' => $bindings[3] ?? true, + 'is_available' => $bindings[3] ?? true, ]; return 1; @@ -200,9 +200,9 @@ public function lastInsertId(): int it('finds entities by criteria', function (): void { $storage = [ - 1 => ['id' => 1, 'name' => 'Active Product', 'price' => 10.0, 'stock' => 5, 'isAvailable' => true], - 2 => ['id' => 2, 'name' => 'Inactive Product', 'price' => 20.0, 'stock' => 0, 'isAvailable' => false], - 3 => ['id' => 3, 'name' => 'Another Active', 'price' => 15.0, 'stock' => 3, 'isAvailable' => true], + 1 => ['id' => 1, 'name' => 'Active Product', 'price' => 10.0, 'stock' => 5, 'is_available' => true], + 2 => ['id' => 2, 'name' => 'Inactive Product', 'price' => 20.0, 'stock' => 0, 'is_available' => false], + 3 => ['id' => 3, 'name' => 'Another Active', 'price' => 15.0, 'stock' => 3, 'is_available' => true], ]; $connection = new readonly class ($storage) implements ConnectionInterface @@ -225,12 +225,12 @@ public function query( array $bindings = [], ): array { // Simulate findBy with isAvailable criteria - if (str_contains($sql, 'isAvailable = ?')) { + if (str_contains($sql, 'is_available = ?')) { $searchValue = $bindings[0]; return array_values(array_filter( $this->storage, - fn ($row) => $row['isAvailable'] === $searchValue, + fn ($row) => $row['is_available'] === $searchValue, )); } @@ -367,7 +367,7 @@ public function lastInsertId(): int it('checks entity existence', function (): void { $storage = [ - 1 => ['id' => 1, 'name' => 'Existing', 'price' => 10.0, 'stock' => 1, 'isAvailable' => true], + 1 => ['id' => 1, 'name' => 'Existing', 'price' => 10.0, 'stock' => 1, 'is_available' => true], ]; $connection = new readonly class ($storage) implements ConnectionInterface diff --git a/packages/database/tests/Repository/RepositoryTest.php b/packages/database/tests/Repository/RepositoryTest.php index 6c2e6c0e..a811cc6e 100644 --- a/packages/database/tests/Repository/RepositoryTest.php +++ b/packages/database/tests/Repository/RepositoryTest.php @@ -185,13 +185,13 @@ class InvalidRepository extends Repository {} // - 'id' maps to 'id' column // - 'name' maps to 'name' column // - 'email' maps to 'email_address' column (explicit in #[Column('email_address')]) - // - 'isActive' maps to 'isActive' column (no explicit name, uses property name) + // - 'isActive' maps to 'is_active' column (no explicit name, uses snake_case of property name) $connection = createMockConnection([ [ 'id' => 1, 'name' => 'John Doe', 'email_address' => 'john@example.com', - 'isActive' => 1, + 'is_active' => 1, ], ]); $metadataFactory = new EntityMetadataFactory(); @@ -230,7 +230,7 @@ class InvalidRepository extends Repository {} 'id' => 42, 'name' => 'Jane Doe', 'email_address' => 'jane@example.com', - 'isActive' => 1, + 'is_active' => 1, ], ]); $metadataFactory = new EntityMetadataFactory(); @@ -279,7 +279,7 @@ class InvalidRepository extends Repository {} 'id' => 42, 'name' => 'Jane Doe', 'email_address' => 'jane@example.com', - 'isActive' => 1, + 'is_active' => 1, ], ]); $metadataFactory = new EntityMetadataFactory(); @@ -307,8 +307,8 @@ class InvalidRepository extends Repository {} it('finds all entities with findAll()', function (): void { $connection = createMockConnection([ - ['id' => 1, 'name' => 'Alice', 'email_address' => 'alice@example.com', 'isActive' => 1], - ['id' => 2, 'name' => 'Bob', 'email_address' => 'bob@example.com', 'isActive' => 0], + ['id' => 1, 'name' => 'Alice', 'email_address' => 'alice@example.com', 'is_active' => 1], + ['id' => 2, 'name' => 'Bob', 'email_address' => 'bob@example.com', 'is_active' => 0], ]); $metadataFactory = new EntityMetadataFactory(); $hydrator = new EntityHydrator(); @@ -324,8 +324,8 @@ class InvalidRepository extends Repository {} it('finds entities by criteria array with findBy(array)', function (): void { $connection = createMockConnection([ - ['id' => 1, 'name' => 'Alice', 'email_address' => 'alice@example.com', 'isActive' => 1], - ['id' => 3, 'name' => 'Charlie', 'email_address' => 'charlie@example.com', 'isActive' => 1], + ['id' => 1, 'name' => 'Alice', 'email_address' => 'alice@example.com', 'is_active' => 1], + ['id' => 3, 'name' => 'Charlie', 'email_address' => 'charlie@example.com', 'is_active' => 1], ]); $metadataFactory = new EntityMetadataFactory(); $hydrator = new EntityHydrator(); @@ -340,7 +340,7 @@ class InvalidRepository extends Repository {} it('finds single entity by criteria with findOneBy(array)', function (): void { $connection = createMockConnection([ - ['id' => 2, 'name' => 'Bob', 'email_address' => 'bob@example.com', 'isActive' => 1], + ['id' => 2, 'name' => 'Bob', 'email_address' => 'bob@example.com', 'is_active' => 1], ]); $metadataFactory = new EntityMetadataFactory(); $hydrator = new EntityHydrator(); @@ -449,7 +449,7 @@ public function query( $this->firstQuery = false; return [ - ['id' => 1, 'name' => 'Original', 'email_address' => 'orig@example.com', 'isActive' => 1], + ['id' => 1, 'name' => 'Original', 'email_address' => 'orig@example.com', 'is_active' => 1], ]; } @@ -524,7 +524,7 @@ public function query( $this->firstQuery = false; return [ - ['id' => 1, 'name' => 'Original', 'email_address' => 'orig@example.com', 'isActive' => 1], + ['id' => 1, 'name' => 'Original', 'email_address' => 'orig@example.com', 'is_active' => 1], ]; } @@ -574,7 +574,7 @@ public function lastInsertId(): int $setClause = substr($sql, strpos($sql, 'SET'), strpos($sql, 'WHERE') - strpos($sql, 'SET')); expect($sql)->toContain('name') ->and($setClause)->not->toContain('email_address') - ->and($setClause)->not->toContain('isActive'); + ->and($setClause)->not->toContain('is_active'); }); it('sets auto-generated ID on entity after insert', function (): void { @@ -662,7 +662,7 @@ public function query( $this->firstQuery = false; return [ - ['id' => 5, 'name' => 'ToDelete', 'email_address' => 'del@example.com', 'isActive' => 1], + ['id' => 5, 'name' => 'ToDelete', 'email_address' => 'del@example.com', 'is_active' => 1], ]; } @@ -721,7 +721,7 @@ public function lastInsertId(): int it('hydrates results from query() automatically', function (): void { $connection = createMockConnection([ - ['id' => 1, 'name' => 'QueryUser', 'email_address' => 'query@example.com', 'isActive' => 1], + ['id' => 1, 'name' => 'QueryUser', 'email_address' => 'query@example.com', 'is_active' => 1], ]); $metadataFactory = new EntityMetadataFactory(); $hydrator = new EntityHydrator(); @@ -816,7 +816,7 @@ public function query( // First call (id=1) returns exists, second call (id=999) returns not exists if ($this->queryCount === 1 && $bindings[0] === 1) { - return [['id' => 1, 'name' => 'Exists', 'email_address' => 'exists@example.com', 'isActive' => 1]]; + return [['id' => 1, 'name' => 'Exists', 'email_address' => 'exists@example.com', 'is_active' => 1]]; } return []; diff --git a/packages/media/src/Entity/Media.php b/packages/media/src/Entity/Media.php index f5f144bb..0b7b45c6 100644 --- a/packages/media/src/Entity/Media.php +++ b/packages/media/src/Entity/Media.php @@ -17,10 +17,10 @@ class Media extends Entity #[Column(length: 255)] public string $filename = ''; - #[Column('original_filename', length: 255)] + #[Column(length: 255)] public string $originalFilename = ''; - #[Column('mime_type', length: 100)] + #[Column(length: 100)] public string $mimeType = ''; #[Column] @@ -35,9 +35,9 @@ class Media extends Entity #[Column(type: 'TEXT')] public ?string $metadata = null; - #[Column('created_at')] + #[Column] public ?string $createdAt = null; - #[Column('updated_at')] + #[Column] public ?string $updatedAt = null; } diff --git a/packages/media/src/Entity/MediaAttachment.php b/packages/media/src/Entity/MediaAttachment.php index fc35c736..83893cdb 100644 --- a/packages/media/src/Entity/MediaAttachment.php +++ b/packages/media/src/Entity/MediaAttachment.php @@ -14,12 +14,12 @@ class MediaAttachment extends Entity #[Column(primaryKey: true, autoIncrement: true)] public ?int $id = null; - #[Column('media_id')] + #[Column] public int $mediaId = 0; - #[Column('attachable_type', length: 255)] + #[Column(length: 255)] public string $attachableType = ''; - #[Column('attachable_id', length: 255)] + #[Column(length: 255)] public int|string $attachableId = 0; } diff --git a/packages/media/tests/Entity/MediaTest.php b/packages/media/tests/Entity/MediaTest.php index 49e571c8..315b7b25 100644 --- a/packages/media/tests/Entity/MediaTest.php +++ b/packages/media/tests/Entity/MediaTest.php @@ -42,7 +42,7 @@ $originalFilenameAttrs = $originalFilenameProp->getAttributes(Column::class); $originalFilenameColumn = $originalFilenameAttrs[0]->newInstance(); expect($originalFilenameAttrs)->toHaveCount(1) - ->and($originalFilenameColumn->name)->toBe('original_filename') + ->and($originalFilenameColumn->name)->toBeNull() ->and($originalFilenameColumn->length)->toBe(255); // Verify mime_type property @@ -50,7 +50,7 @@ $mimeTypeAttrs = $mimeTypeProp->getAttributes(Column::class); $mimeTypeColumn = $mimeTypeAttrs[0]->newInstance(); expect($mimeTypeAttrs)->toHaveCount(1) - ->and($mimeTypeColumn->name)->toBe('mime_type') + ->and($mimeTypeColumn->name)->toBeNull() ->and($mimeTypeColumn->length)->toBe(100); // Verify size property @@ -85,7 +85,7 @@ $createdAtAttrs = $createdAtProp->getAttributes(Column::class); $createdAtColumn = $createdAtAttrs[0]->newInstance(); expect($createdAtAttrs)->toHaveCount(1) - ->and($createdAtColumn->name)->toBe('created_at') + ->and($createdAtColumn->name)->toBeNull() ->and($createdAtProp->getType()->allowsNull())->toBeTrue(); // Verify updated_at property @@ -93,6 +93,6 @@ $updatedAtAttrs = $updatedAtProp->getAttributes(Column::class); $updatedAtColumn = $updatedAtAttrs[0]->newInstance(); expect($updatedAtAttrs)->toHaveCount(1) - ->and($updatedAtColumn->name)->toBe('updated_at') + ->and($updatedAtColumn->name)->toBeNull() ->and($updatedAtProp->getType()->allowsNull())->toBeTrue(); }); diff --git a/packages/webhook/src/Entity/WebhookAttempt.php b/packages/webhook/src/Entity/WebhookAttempt.php index 641ff3f3..3385d7c3 100644 --- a/packages/webhook/src/Entity/WebhookAttempt.php +++ b/packages/webhook/src/Entity/WebhookAttempt.php @@ -14,24 +14,24 @@ class WebhookAttempt extends Entity #[Column(primaryKey: true, autoIncrement: true)] public ?int $id = null; - #[Column(name: 'status_code')] + #[Column] public ?int $statusCode = null; - #[Column(name: 'response_body', type: 'TEXT')] + #[Column(type: 'TEXT')] public ?string $responseBody = null; - #[Column(name: 'error_message', type: 'TEXT')] + #[Column(type: 'TEXT')] public ?string $errorMessage = null; - #[Column(name: 'attempted_at')] + #[Column] public ?string $attemptedAt = null; public function __construct( - #[Column(name: 'webhook_url')] + #[Column] public string $webhookUrl = '', #[Column] public string $event = '', - #[Column(name: 'attempt_number')] + #[Column] public int $attemptNumber = 1, ) {} }