From 172a155f54dfb7f15816474c43035b97ebc095cb Mon Sep 17 00:00:00 2001 From: Dave Barnwell Date: Sat, 7 Mar 2026 22:16:43 +0000 Subject: [PATCH 1/3] Change magic model methods to camelCase --- src/Model/Model.php | 202 ++++++++++++++++++++++++++--------- test-src/Model/Category.php | 7 +- tests/Model/CategoryTest.php | 87 +++++++++------ 3 files changed, 209 insertions(+), 87 deletions(-) diff --git a/src/Model/Model.php b/src/Model/Model.php index bc83bc0..cbbb6cd 100644 --- a/src/Model/Model.php +++ b/src/Model/Model.php @@ -490,16 +490,16 @@ public static function last(): ?static */ public static function find($id) { - $find_by_method = 'find_by_' . (static::$_primary_column_name); - return static::$find_by_method($id); + return static::fetchAllWhereMatchingSingleField(static::resolveFieldName(static::$_primary_column_name), $id); } /** * handles calls to non-existant static methods, used to implement dynamic finder and counters ie. - * find_by_name('tom') - * find_by_title('a great book') - * count_by_name('tom') - * count_by_title('a great book') + * findByName('tom') + * findByTitle('a great book') + * countByName('tom') + * countByTitle('a great book') + * snake_case dynamic methods remain temporarily supported and trigger a deprecation warning. * * @param string $name * @param array $arguments @@ -509,52 +509,135 @@ public static function find($id) */ public static function __callStatic($name, $arguments) { - // Note: value of $name is case sensitive. $match = $arguments[0] ?? null; - if (preg_match('/^find_by_/', $name) == 1) { - // it's a find_by_{fieldname} dynamic method - $fieldname = substr($name, 8); // remove find by - return static::fetchAllWhereMatchingSingleField(static::resolveFieldName($fieldname), $match); - } elseif (preg_match('/^findOne_by_/', $name) == 1) { - // it's a findOne_by_{fieldname} dynamic method - $fieldname = substr($name, 11); // remove findOne_by_ - return static::fetchOneWhereMatchingSingleField(static::resolveFieldName($fieldname), $match, 'ASC'); - } elseif (preg_match('/^first_by_/', $name) == 1) { - // it's a first_by_{fieldname} dynamic method - $fieldname = substr($name, 9); // remove first_by_ - return static::fetchOneWhereMatchingSingleField(static::resolveFieldName($fieldname), $match, 'ASC'); - } elseif (preg_match('/^last_by_/', $name) == 1) { - // it's a last_by_{fieldname} dynamic method - $fieldname = substr($name, 8); // remove last_by_ - return static::fetchOneWhereMatchingSingleField(static::resolveFieldName($fieldname), $match, 'DESC'); - } elseif (preg_match('/^count_by_/', $name) == 1) { - // it's a count_by_{fieldname} dynamic method - $fieldname = substr($name, 9); // remove find by - return static::countByField(static::resolveFieldName($fieldname), $match); - } elseif (preg_match('/^findBy/', $name) == 1) { - // it's a findBy{Fieldname} dynamic method - $fieldname = substr($name, 6); // remove findBy - return static::fetchAllWhereMatchingSingleField(static::resolveFieldName($fieldname), $match); - } elseif (preg_match('/^findOneBy/', $name) == 1) { - // it's a findOneBy{Fieldname} dynamic method - $fieldname = substr($name, 9); // remove findOneBy - return static::fetchOneWhereMatchingSingleField(static::resolveFieldName($fieldname), $match, 'ASC'); - } elseif (preg_match('/^firstBy/', $name) == 1) { - // it's a firstBy{Fieldname} dynamic method - $fieldname = substr($name, 7); // remove firstBy - return static::fetchOneWhereMatchingSingleField(static::resolveFieldName($fieldname), $match, 'ASC'); - } elseif (preg_match('/^lastBy/', $name) == 1) { - // it's a lastBy{Fieldname} dynamic method - $fieldname = substr($name, 6); // remove lastBy - return static::fetchOneWhereMatchingSingleField(static::resolveFieldName($fieldname), $match, 'DESC'); - } elseif (preg_match('/^countBy/', $name) == 1) { - // it's a countBy{Fieldname} dynamic method - $fieldname = substr($name, 7); // remove countBy - return static::countByField(static::resolveFieldName($fieldname), $match); + $dynamicMethod = static::parseDynamicStaticMethod($name); + if (is_array($dynamicMethod)) { + if ($dynamicMethod['deprecated']) { + static::triggerSnakeCaseDynamicMethodDeprecation($name); + } + return static::dispatchDynamicStaticMethod($dynamicMethod['operation'], $dynamicMethod['fieldname'], $match); } throw new \Exception(__CLASS__ . ' not such static method[' . $name . ']'); } + /** + * Parse supported dynamic static finder/counter names. + * + * @param string $name + * + * @return array{operation: string, fieldname: string, deprecated: bool}|null + */ + protected static function parseDynamicStaticMethod(string $name): ?array + { + $camelCasePrefixes = array( + 'findOneBy' => 'findOne', + 'findBy' => 'findAll', + 'firstBy' => 'first', + 'lastBy' => 'last', + 'countBy' => 'count', + ); + foreach ($camelCasePrefixes as $prefix => $operation) { + if (str_starts_with($name, $prefix)) { + $fieldname = substr($name, strlen($prefix)); + if ($fieldname === '') { + return null; + } + return array( + 'operation' => $operation, + 'fieldname' => $fieldname, + 'deprecated' => false, + ); + } + } + + $snakeCasePrefixes = array( + 'findOne_by_' => 'findOne', + 'find_by_' => 'findAll', + 'first_by_' => 'first', + 'last_by_' => 'last', + 'count_by_' => 'count', + ); + foreach ($snakeCasePrefixes as $prefix => $operation) { + if (str_starts_with($name, $prefix)) { + $fieldname = substr($name, strlen($prefix)); + if ($fieldname === '') { + return null; + } + return array( + 'operation' => $operation, + 'fieldname' => $fieldname, + 'deprecated' => true, + ); + } + } + + return null; + } + + /** + * Execute a parsed dynamic static method. + * + * @param string $operation + * @param string $fieldname + * @param mixed $match + * + * @return mixed + * @throws \Exception + */ + protected static function dispatchDynamicStaticMethod(string $operation, string $fieldname, $match) + { + $resolvedFieldname = static::resolveFieldName($fieldname); + + return match ($operation) { + 'findAll' => static::fetchAllWhereMatchingSingleField($resolvedFieldname, $match), + 'findOne' => static::fetchOneWhereMatchingSingleField($resolvedFieldname, $match, 'ASC'), + 'first' => static::fetchOneWhereMatchingSingleField($resolvedFieldname, $match, 'ASC'), + 'last' => static::fetchOneWhereMatchingSingleField($resolvedFieldname, $match, 'DESC'), + 'count' => static::countByField($resolvedFieldname, $match), + default => throw new \Exception(__CLASS__ . ' not such static method operation[' . $operation . ']'), + }; + } + + /** + * Warn when a deprecated snake_case dynamic method is used. + * + * @param string $name + * + * @return void + */ + protected static function triggerSnakeCaseDynamicMethodDeprecation(string $name): void + { + $replacement = static::snakeCaseDynamicMethodToCamelCase($name); + $message = 'Dynamic snake_case model methods are deprecated. Use ' . $replacement . ' instead of ' . $name . '.'; + trigger_error($message, E_USER_DEPRECATED); + } + + /** + * Convert a snake_case dynamic method name to the camelCase replacement. + * + * @param string $name + * + * @return string + */ + protected static function snakeCaseDynamicMethodToCamelCase(string $name): string + { + $prefixMap = array( + 'findOne_by_' => 'findOneBy', + 'find_by_' => 'findBy', + 'first_by_' => 'firstBy', + 'last_by_' => 'lastBy', + 'count_by_' => 'countBy', + ); + foreach ($prefixMap as $prefix => $replacementPrefix) { + if (str_starts_with($name, $prefix)) { + $fieldname = substr($name, strlen($prefix)); + return $replacementPrefix . static::snakeToStudly($fieldname); + } + } + + return $name; + } + /** * Resolve a dynamic field name from snake_case or CamelCase to an actual column name. * @@ -599,6 +682,21 @@ protected static function camelToSnake($fieldname) return strtolower($snake ?? $fieldname); } + /** + * Convert snake_case to StudlyCase for dynamic method generation. + * + * @param string $fieldname + * + * @return string + */ + protected static function snakeToStudly(string $fieldname): string + { + $parts = explode('_', $fieldname); + $parts = array_map(static fn ($part) => ucfirst(strtolower($part)), $parts); + + return implode('', $parts); + } + /** * Count records for a field with either a single value or an array of values. * @@ -618,9 +716,9 @@ protected static function countByField($fieldname, $match) /** * find one match based on a single field and match criteria * - * @param string $fieldname - * @param string|array $match - * @param string $order ASC|DESC + * @param string $fieldname + * @param mixed $match + * @param string $order ASC|DESC * * @return static|null object of calling class */ @@ -637,8 +735,8 @@ public static function fetchOneWhereMatchingSingleField($fieldname, $match, $ord /** * find multiple matches based on a single field and match criteria * - * @param string $fieldname - * @param string|array $match + * @param string $fieldname + * @param mixed $match * * @return object[] of objects of calling class */ diff --git a/test-src/Model/Category.php b/test-src/Model/Category.php index 887c271..b91e63b 100644 --- a/test-src/Model/Category.php +++ b/test-src/Model/Category.php @@ -3,16 +3,13 @@ namespace App\Model; /** - * @method static array find_by_name($match) - * @method static self|null findOne_by_name($match) - * @method static self|null first_by_name($match) - * @method static self|null last_by_name($match) - * @method static int count_by_name($match) * @method static array findByName($match) * @method static self|null findOneByName($match) * @method static self|null firstByName($match) * @method static self|null lastByName($match) * @method static int countByName($match) + * @method static self|null findOneByUpdatedAt($match) + * Legacy snake_case dynamic methods remain temporarily supported and emit deprecation notices. * @property int|null $id primary key * @property string|null $name category name * @property string|null $updated_at mysql datetime string diff --git a/tests/Model/CategoryTest.php b/tests/Model/CategoryTest.php index f37ffd4..b84470f 100644 --- a/tests/Model/CategoryTest.php +++ b/tests/Model/CategoryTest.php @@ -186,7 +186,7 @@ public function testFetchAllWhere(): void )); $category->save(); // no Id so will insert } - $categories = App\Model\Category::find_by_name($_names); + $categories = App\Model\Category::findByName($_names); $this->assertNotEmpty($categories); $this->assertContainsOnlyInstancesOf('App\Model\Category', $categories); $this->assertCount(count($_names), $categories); @@ -268,62 +268,65 @@ public function testRecordLifecycleHelpers(): void $this->assertSame(0, App\Model\Category::count()); } - public function testDynamicFindersSnakeCase(): void + public function testDynamicFindersCamelCase(): void { $_names = [ - 'Snake_' . uniqid('a_', true), - 'Snake_' . uniqid('b_', true), + 'Camel_' . uniqid('a_', true), + 'Camel_' . uniqid('b_', true), ]; foreach ($_names as $_name) { $this->createCategory($_name); } - $one = App\Model\Category::findOne_by_name($_names[0]); + $categories = App\Model\Category::findByName($_names); + $this->assertCount(count($_names), $categories); + + $one = App\Model\Category::findOneByName($_names[0]); $this->assertNotNull($one); $this->assertSame($_names[0], $one->name); - $first = App\Model\Category::first_by_name($_names); + $first = App\Model\Category::firstByName($_names); $this->assertNotNull($first); $this->assertContains($first->name, $_names); - $last = App\Model\Category::last_by_name($_names); + $last = App\Model\Category::lastByName($_names); $this->assertNotNull($last); $this->assertContains($last->name, $_names); - $count = App\Model\Category::count_by_name($_names); + $count = App\Model\Category::countByName($_names); $this->assertSame(count($_names), $count); } - public function testDynamicFindersCamelCase(): void + public function testDynamicFindersCamelCaseResolveSnakeCaseColumns(): void + { + $category = $this->createCategory('Timestamp_' . uniqid('', true)); + $one = App\Model\Category::findOneByUpdatedAt($category->updated_at); + $this->assertNotNull($one); + $this->assertSame((int) $category->id, (int) $one->id); + } + + public function testDynamicFindersSnakeCaseEmitDeprecation(): void { $_names = [ - 'Camel_' . uniqid('a_', true), - 'Camel_' . uniqid('b_', true), + 'Snake_' . uniqid('a_', true), + 'Snake_' . uniqid('b_', true), ]; foreach ($_names as $_name) { - $category = new App\Model\Category(array( - 'name' => $_name - )); - $category->save(); + $this->createCategory($_name); } - $categories = App\Model\Category::findByName($_names); - $this->assertCount(count($_names), $categories); + $this->assertSame(count($_names), $this->captureUserDeprecation( + 'Dynamic snake_case model methods are deprecated. Use countByName instead of count_by_name.', + static fn () => App\Model\Category::__callStatic('count_by_name', [$_names]) + )); - $one = App\Model\Category::findOneByName($_names[0]); + /** @var App\Model\Category|null $one */ + $one = $this->captureUserDeprecation( + 'Dynamic snake_case model methods are deprecated. Use findOneByName instead of findOne_by_name.', + static fn () => App\Model\Category::__callStatic('findOne_by_name', [$_names[0]]) + ); $this->assertNotNull($one); - $this->assertEquals($_names[0], $one->name); - - $first = App\Model\Category::firstByName($_names); - $this->assertNotNull($first); - $this->assertContains($first->name, $_names); - - $last = App\Model\Category::lastByName($_names); - $this->assertNotNull($last); - $this->assertContains($last->name, $_names); - - $count = App\Model\Category::countByName($_names); - $this->assertEquals(count($_names), $count); + $this->assertSame($_names[0], $one->name); } public function testInsertAllowsExplicitPrimaryKey(): void @@ -352,4 +355,28 @@ public function testUnknownDynamicMethodThrows(): void App\Model\Category::__callStatic('doesNotExist', ['value']); } + + private function captureUserDeprecation(string $expectedMessage, callable $callback): mixed + { + $result = null; + $captured = null; + + set_error_handler(static function (int $severity, string $message) use (&$captured): bool { + if ($severity !== E_USER_DEPRECATED) { + return false; + } + $captured = $message; + return true; + }); + + try { + $result = $callback(); + } finally { + restore_error_handler(); + } + + $this->assertSame($expectedMessage, $captured); + + return $result; + } } From 9d6cb39f01738ff8becc4e9e795dd1ad941d09dc Mon Sep 17 00:00:00 2001 From: Dave Barnwell Date: Sat, 7 Mar 2026 22:20:42 +0000 Subject: [PATCH 2/3] Update README for camelCase dynamic methods --- README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7304351..7259ce9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ It is designed for projects that value straightforward PHP, direct PDO access, a - Minimal setup: define a model class and table name, then start reading and writing rows. - PDO-first: use the ORM helpers when they help and drop down to raw SQL when they do not. - Familiar model flow: create, hydrate, validate, save, update, count, find, and delete. -- Dynamic finders: call methods such as `find_by_name()`, `findOneByName()`, `count_by_name()`, and more. +- Dynamic finders: call methods such as `findByName()`, `findOneByName()`, `countByName()`, and more. - Multi-database support: tested against MySQL/MariaDB and PostgreSQL, with SQLite code paths also supported. ## Installation @@ -118,16 +118,18 @@ Timestamp columns named `created_at` and `updated_at` are populated automaticall ### Dynamic finders and counters -You can query using snake_case or CamelCase method names: +You can query using camelCase dynamic method names: ```php -Category::find_by_name('Science Fiction'); -Category::findOne_by_name('Science Fiction'); -Category::first_by_name(['Sci-Fi', 'Fantasy']); +Category::findByName('Science Fiction'); +Category::findOneByName('Science Fiction'); +Category::firstByName(['Sci-Fi', 'Fantasy']); Category::lastByName(['Sci-Fi', 'Fantasy']); -Category::count_by_name('Science Fiction'); +Category::countByName('Science Fiction'); ``` +Legacy snake_case dynamic methods remain available during the transition, but they are deprecated and emit `E_USER_DEPRECATED` notices. + ### Custom where clauses When you need more control, fetch one or many records with SQL fragments: From 2a4510ee0e0f33cfb0331c18b7dd8b8931736a4a Mon Sep 17 00:00:00 2001 From: Dave Barnwell Date: Sat, 7 Mar 2026 22:22:50 +0000 Subject: [PATCH 3/3] Address PR review comments --- src/Model/Model.php | 4 ++-- tests/Model/CategoryTest.php | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/Model/Model.php b/src/Model/Model.php index cbbb6cd..f1abe48 100644 --- a/src/Model/Model.php +++ b/src/Model/Model.php @@ -494,7 +494,7 @@ public static function find($id) } /** - * handles calls to non-existant static methods, used to implement dynamic finder and counters ie. + * handles calls to non-existent static methods, used to implement dynamic finder and counters ie. * findByName('tom') * findByTitle('a great book') * countByName('tom') @@ -594,7 +594,7 @@ protected static function dispatchDynamicStaticMethod(string $operation, string 'first' => static::fetchOneWhereMatchingSingleField($resolvedFieldname, $match, 'ASC'), 'last' => static::fetchOneWhereMatchingSingleField($resolvedFieldname, $match, 'DESC'), 'count' => static::countByField($resolvedFieldname, $match), - default => throw new \Exception(__CLASS__ . ' not such static method operation[' . $operation . ']'), + default => throw new \Exception(static::class . ' not such static method operation[' . $operation . ']'), }; } diff --git a/tests/Model/CategoryTest.php b/tests/Model/CategoryTest.php index b84470f..2350609 100644 --- a/tests/Model/CategoryTest.php +++ b/tests/Model/CategoryTest.php @@ -320,6 +320,13 @@ public function testDynamicFindersSnakeCaseEmitDeprecation(): void static fn () => App\Model\Category::__callStatic('count_by_name', [$_names]) )); + /** @var App\Model\Category[] $categories */ + $categories = $this->captureUserDeprecation( + 'Dynamic snake_case model methods are deprecated. Use findByName instead of find_by_name.', + static fn () => App\Model\Category::__callStatic('find_by_name', [$_names]) + ); + $this->assertCount(count($_names), $categories); + /** @var App\Model\Category|null $one */ $one = $this->captureUserDeprecation( 'Dynamic snake_case model methods are deprecated. Use findOneByName instead of findOne_by_name.', @@ -327,6 +334,22 @@ public function testDynamicFindersSnakeCaseEmitDeprecation(): void ); $this->assertNotNull($one); $this->assertSame($_names[0], $one->name); + + /** @var App\Model\Category|null $first */ + $first = $this->captureUserDeprecation( + 'Dynamic snake_case model methods are deprecated. Use firstByName instead of first_by_name.', + static fn () => App\Model\Category::__callStatic('first_by_name', [$_names]) + ); + $this->assertNotNull($first); + $this->assertContains($first->name, $_names); + + /** @var App\Model\Category|null $last */ + $last = $this->captureUserDeprecation( + 'Dynamic snake_case model methods are deprecated. Use lastByName instead of last_by_name.', + static fn () => App\Model\Category::__callStatic('last_by_name', [$_names]) + ); + $this->assertNotNull($last); + $this->assertContains($last->name, $_names); } public function testInsertAllowsExplicitPrimaryKey(): void