Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,12 @@ Return an object for the first matching the name
Category::findOne_by_name('changed name');
```

CamelCase alternatives are also supported:

```php
Category::findOneByName('changed name');
```

Return an object for the first match from the names

```php
Expand All @@ -167,6 +173,10 @@ Return an array of objects that match the name
Category::find_by_name('changed name');
```

```php
Category::findByName('changed name');
```

Return an array of objects that match the names

```php
Expand Down
100 changes: 86 additions & 14 deletions src/Model/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -510,39 +510,111 @@ 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
$match = $arguments[0];
return static::fetchAllWhereMatchingSingleField($fieldname, $match);
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_
$match = $arguments[0];
return static::fetchOneWhereMatchingSingleField($fieldname, $match, 'ASC');
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_
$match = $arguments[0];
return static::fetchOneWhereMatchingSingleField($fieldname, $match, 'ASC');
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_
$match = $arguments[0];
return static::fetchOneWhereMatchingSingleField($fieldname, $match, 'DESC');
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
$match = $arguments[0];
if (is_array($match)) {
return static::countAllWhere(static::_quote_identifier($fieldname) . ' IN (' . static::createInClausePlaceholders($match) . ')', $match);
} else {
return static::countAllWhere(static::_quote_identifier($fieldname) . ' = ?', array($match));
}
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);
}
throw new \Exception(__CLASS__ . ' not such static method[' . $name . ']');
}

/**
* Resolve a dynamic field name from snake_case or CamelCase to an actual column name.
*
* @param string $fieldname
*
* @return string
* @throws \Exception
*/
protected static function resolveFieldName($fieldname)
{
$fieldnames = static::getFieldnames();
if (in_array($fieldname, $fieldnames, true)) {
return $fieldname;
}
foreach ($fieldnames as $field) {
if (strcasecmp($field, $fieldname) === 0) {
return $field;
}
}
$snake = static::camelToSnake($fieldname);
if (in_array($snake, $fieldnames, true)) {
return $snake;
}
foreach ($fieldnames as $field) {
if (strcasecmp($field, $snake) === 0) {
return $field;
}
}
return $fieldname;
}

/**
* Convert CamelCase or mixedCase to snake_case.
*
* @param string $fieldname
*
* @return string
*/
protected static function camelToSnake($fieldname)
{
$snake = preg_replace('/(?<!^)[A-Z]/', '_$0', $fieldname);
return strtolower($snake ?? $fieldname);
Comment on lines +596 to +599

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle acronym segments when converting camelCase to snake_case

The new camelToSnake helper splits before every capital letter, so camelCase names with acronym runs (e.g., findByProductID or findByURLValue) become product_i_d/u_r_l_value. resolveFieldName then fails to match the actual snake_case column (e.g., product_id or url_value) and falls back to the original productID, which is quoted and queried as a non-existent column. This will throw SQL errors or return no rows whenever a model has acronym-style column names and users rely on the new camelCase dynamic finders. Consider a regex that collapses consecutive capitals into a single word boundary.

Useful? React with 👍 / 👎.

}
Comment on lines +566 to +600
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new resolveFieldName/camelToSnake logic that maps CamelCase method segments to snake_case column names (e.g. findByCreatedAt -> created_at) is not covered by tests; the current testDynamicFindersCamelCase only exercises simple case-insensitive matching for name and never hits the camel-to-snake path. Please add test coverage for at least one snake_case column (such as created_at) using the new CamelCase dynamic methods so this behavior is verified and guarded against regressions.

Copilot uses AI. Check for mistakes.

/**
* Count records for a field with either a single value or an array of values.
*
* @param string $fieldname
* @param mixed $match
*
* @return int
*/
protected static function countByField($fieldname, $match)
{
if (is_array($match)) {
return static::countAllWhere(static::_quote_identifier($fieldname) . ' IN (' . static::createInClausePlaceholders($match) . ')', $match);
}
return static::countAllWhere(static::_quote_identifier($fieldname) . ' = ?', array($match));
}

/**
* find one match based on a single field and match criteria
*
Expand Down
5 changes: 5 additions & 0 deletions test-src/Model/Category.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

/**
* @method static array find_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)
* @property int|null $id primary key
* @property string|null $name category name
* @property string|null $updated_at mysql datetime string
Expand Down
38 changes: 38 additions & 0 deletions tests/Model/CategoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -207,4 +207,42 @@ public function testFetchAllWhere(): void
$this->assertContainsOnlyInstancesOf('App\Model\Category', $categories);
$this->assertCount(count($_names), $categories);
}

/**
* @covers ::__callStatic
* @covers ::fetchAllWhereMatchingSingleField
* @covers ::fetchOneWhereMatchingSingleField
* @covers ::countAllWhere
*/
public function testDynamicFindersCamelCase(): void
{
$_names = [
'Camel_' . uniqid('a_', true),
'Camel_' . uniqid('b_', true),
];
foreach ($_names as $_name) {
$category = new App\Model\Category(array(
'name' => $_name
));
$category->save();
}

$categories = App\Model\Category::findByName($_names);
$this->assertCount(count($_names), $categories);

$one = App\Model\Category::findOneByName($_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);
}
}