Fix TypeError when saving integer CPU core count in global settings#376
Fix TypeError when saving integer CPU core count in global settings#376
Conversation
Agent-Logs-Url: https://github.com/R0Wi-DEV/workflow_ocr/sessions/e5743218-a210-416d-b782-64fc77d0fb16 Co-authored-by: R0Wi <19730957+R0Wi@users.noreply.github.com>
…erties Agent-Logs-Url: https://github.com/R0Wi-DEV/workflow_ocr/sessions/ca3e8666-830a-42ce-9ae2-c2dbd88e3dc9 Co-authored-by: R0Wi <19730957+R0Wi@users.noreply.github.com>
13d0e3f to
2f65fec
Compare
…ull and improve type management
There was a problem hiding this comment.
Pull request overview
Fixes a TypeError when persisting integer-valued global settings (CPU core count / timeout) by moving these settings to true integers end-to-end (model → controller/service → appconfig), including a migration for existing string-typed config entries.
Changes:
- Convert
GlobalSettingsnumeric fields to?intand persist them viaIAppConfig::getValueInt()/setValueInt()using reflection-based type detection. - Add a migration to convert legacy
VALUE_STRINGappconfig entries forprocessorCount/timeoutintoVALUE_INT. - Update Vue UI + unit tests (PHP + JS) to treat these values as numbers and allow null/unset behavior.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
lib/Model/GlobalSettings.php |
Change global settings fields to typed ?int. |
lib/Service/GlobalSettingsService.php |
Read/write int settings via getValueInt/setValueInt, delete keys when nullable and null. |
lib/Controller/GlobalSettingsController.php |
Cast incoming request payload values to ints/null. |
lib/Migration/Version1034Date20260331194630.php |
Migrate stored config keys from string type to int type. |
lib/OcrProcessors/Remote/Client/ApiClient.php |
Simplify timeout resolution now that settings are typed ints. |
lib/OcrProcessors/CommandLineUtils.php |
Stop string→int coercion for processor count; treat null as unset. |
psalm.xml |
Exclude lib/Migration from Psalm analysis. |
tests/psalm-baseline.xml |
Remove baseline entries for migration unused-class warnings. |
tests/Unit/Service/GlobalSettingsServiceTest.php |
Update mocks/expectations for int config access + null/unset behavior. |
tests/Unit/Controller/GlobalSettingsControllerTest.php |
Add coverage for passing null settings through the controller. |
tests/Unit/OcrProcessors/Remote/Client/ApiClientTest.php |
Update timeout tests to use ints/null. |
src/components/GlobalSettings.vue |
Convert numeric inputs to numbers/null before saving. |
src/test/service/globalSettingsService.spec.js |
Update mocked payload/response types to integers. |
src/test/components/GlobalSettings.spec.js |
Update component tests for numeric values and empty→null behavior. |
| $globalSettingsObject = new GlobalSettings(); | ||
| $globalSettingsObject->processorCount = $globalSettings['processorCount']; | ||
| $globalSettingsObject->timeout = $globalSettings['timeout'] ?? null; | ||
| $globalSettingsObject->processorCount = isset($globalSettings['processorCount']) ? (int)$globalSettings['processorCount'] : null; | ||
| $globalSettingsObject->timeout = isset($globalSettings['timeout']) ? (int)$globalSettings['timeout'] : null; | ||
|
|
There was a problem hiding this comment.
isset($globalSettings['processorCount']) will treat an empty string ("" from an empty number input) as set, and (int)'' becomes 0. That ends up persisting a 0 value even though the service treats <= 0 as “not configured”. Consider coercing "" (and other non-numeric inputs) to null, and ideally normalizing <= 0 to null before calling the service (e.g., via a small helper that returns null unless the parsed value is > 0).
| if ($isNullable && $value === null) { | ||
| $this->config->deleteKey(Application::APP_NAME, $key); | ||
| } elseif ($type instanceof \ReflectionNamedType && $type->getName() === 'int') { | ||
| $this->config->setValueInt(Application::APP_NAME, $key, (int)$value); |
There was a problem hiding this comment.
getGlobalSettings() maps int values <= 0 to null, but setGlobalSettings() will still persist 0/negative values via setValueInt() when they are provided. This creates inconsistent semantics (stored value vs returned value) and can leave invalid values in appconfig. Consider treating non-positive ints as “unset” here too (delete the key or skip writing) to keep storage consistent with the read-path logic.
| $this->config->setValueInt(Application::APP_NAME, $key, (int)$value); | |
| $intValue = (int)$value; | |
| if ($intValue <= 0) { | |
| // Keep storage consistent with read-path semantics: non-positive ints | |
| // are treated as "not configured", so we delete the key instead of | |
| // persisting an invalid/sentinel value. | |
| $this->config->deleteKey(Application::APP_NAME, $key); | |
| } else { | |
| $this->config->setValueInt(Application::APP_NAME, $key, $intValue); | |
| } |
| // requested one, so we must remove the old entry before re-inserting. | ||
| $this->appConfig->deleteKey(Application::APP_NAME, $key); | ||
| $this->appConfig->setValueInt(Application::APP_NAME, $key, $intValue); | ||
|
|
||
| $output->info(sprintf( | ||
| "Migrated config key '%s' for app '%s' from string to int (value: %d).", | ||
| $key, | ||
| Application::APP_NAME, | ||
| $intValue, | ||
| )); |
There was a problem hiding this comment.
The migration currently re-inserts a VALUE_INT even when the legacy string value is empty/non-numeric (mapped to 0). Since the rest of the code treats <= 0 as “not configured”, consider deleting the old key and only re-inserting when the parsed value is actually > 0; otherwise just delete and leave it unset. This avoids persisting a sentinel/invalid value in appconfig.
| // requested one, so we must remove the old entry before re-inserting. | |
| $this->appConfig->deleteKey(Application::APP_NAME, $key); | |
| $this->appConfig->setValueInt(Application::APP_NAME, $key, $intValue); | |
| $output->info(sprintf( | |
| "Migrated config key '%s' for app '%s' from string to int (value: %d).", | |
| $key, | |
| Application::APP_NAME, | |
| $intValue, | |
| )); | |
| // requested one, so we must remove the old entry before deciding whether | |
| // to re-insert it as an integer value. | |
| $this->appConfig->deleteKey(Application::APP_NAME, $key); | |
| if ($intValue > 0) { | |
| $this->appConfig->setValueInt(Application::APP_NAME, $key, $intValue); | |
| $output->info(sprintf( | |
| "Migrated config key '%s' for app '%s' from string to int (value: %d).", | |
| $key, | |
| Application::APP_NAME, | |
| $intValue, | |
| )); | |
| } else { | |
| // For empty/non-numeric or non-positive values, treat as "not configured" | |
| // and leave the key unset instead of persisting a sentinel 0 value. | |
| $output->info(sprintf( | |
| "Deleted legacy string config key '%s' for app '%s' due to non-positive or invalid value.", | |
| $key, | |
| Application::APP_NAME, | |
| )); | |
| } |
| if ($timeoutInt > 0) { | ||
| $timeout = $timeoutInt; | ||
| } | ||
| if ($settings->timeout !== null && $settings->timeout > 0) { |
There was a problem hiding this comment.
getTimeout() is typed as object but directly accesses $settings->timeout without an isset()/property_exists() guard. If anything other than GlobalSettings is ever passed, this will trigger an undefined-property warning. Consider either tightening the parameter type to GlobalSettings (preferred, since that’s what the caller passes) or reintroducing a guard before accessing the property.
| if ($settings->timeout !== null && $settings->timeout > 0) { | |
| if (isset($settings->timeout) && $settings->timeout > 0) { |
| <input | ||
| v-model="settings.processorCount" | ||
| :value="settings.processorCount" | ||
| name="processorCount" | ||
| type="number" | ||
| @input="save"> | ||
| @input="settings.processorCount = Number($event.target.value) || null; save()"> | ||
| </div> |
There was a problem hiding this comment.
The input coercion Number($event.target.value) || null only converts falsy values to null; negative numbers (e.g. “-1”) will be kept and sent to the backend, where they can be persisted even though the service treats <= 0 as unset. Consider explicitly normalizing with a > 0 check (and possibly clamping) so that any <= 0 (and NaN) becomes null.
| <div class="div-table-col"> | ||
| <input | ||
| v-model="settings.timeout" | ||
| :value="settings.timeout" | ||
| name="timeout" | ||
| type="number" | ||
| min="1" | ||
| @input="save"> | ||
| @input="settings.timeout = Number($event.target.value) || null; save()"> | ||
| </div> |
There was a problem hiding this comment.
Same as processorCount: Number($event.target.value) || null allows negative values to flow through. Even though min="1" exists, users can still type negatives manually and the handler will accept them. Consider normalizing in the handler to null unless the parsed number is > 0 to prevent persisting invalid timeouts.
| })); | ||
|
|
||
| $this->controller->setGlobalSettings($settings); | ||
| } |
There was a problem hiding this comment.
The controller now casts values to int/null, but there’s no unit test covering the API behavior when the client submits an empty string (""), which is a common payload from cleared number inputs and is called out in the PR description. Adding a test case for "" → null (or "" → delete/unset) would protect the intended behavior.
| } | |
| } | |
| public function testSetSettingsConvertsEmptyStringsToNull() { | |
| $settings = [ | |
| 'processorCount' => '', | |
| 'timeout' => '', | |
| ]; | |
| $this->globalSettingsService->expects($this->once()) | |
| ->method('setGlobalSettings') | |
| ->with($this->callback(function (GlobalSettings $settings) { | |
| return $settings->processorCount === null && $settings->timeout === null; | |
| })); | |
| $this->controller->setGlobalSettings($settings); | |
| } |
lib/Model/GlobalSettings.php— typed?intpropertieslib/Service/GlobalSettingsService.php— usegetValueInt/setValueIntvia reflection type detectionlib/Controller/GlobalSettingsController.php— cast incoming values to?int(handle""from empty input)lib/OcrProcessors/Remote/Client/ApiClient.php— simplifiedgetTimeout()(removed string checks)lib/OcrProcessors/CommandLineUtils.php— removedintval()casttests/Unit/Service/GlobalSettingsServiceTest.php— updated mocks to usegetValueInt/setValueInttests/Unit/Controller/GlobalSettingsControllerTest.php— added test for empty string → null coerciontests/Unit/OcrProcessors/Remote/Client/ApiClientTest.php— use integer timeout valuessrc/test/service/globalSettingsService.spec.js— updated mock response to integersrc/test/components/GlobalSettings.spec.js— updated initial mock to integer