diff --git a/.github/workflows/action.yml b/.github/workflows/action.yml
new file mode 100644
index 0000000..f092f6f
--- /dev/null
+++ b/.github/workflows/action.yml
@@ -0,0 +1,111 @@
+name: Github Action for CI4 Settings and Feature Flag Library
+
+on:
+ pull_request:
+ branches: [ "main", "develop" ]
+ # Manual runs from the Actions tab
+ workflow_dispatch:
+
+jobs:
+ tests:
+ name: "PHP ${{ matrix.php }} (deps: ${{ matrix.deps }})"
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php: [ "8.2" ]
+ deps: [ "stable" ]
+
+ env:
+ # Typical CI4 testing env values
+ APP_ENV: testing
+ CI_ENVIRONMENT: testing
+
+ steps:
+ # --- 1. Checkout code ---------------------------------------------------
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ # --- 2. Setup PHP -------------------------------------------------------
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: mbstring, intl, json, pdo_mysql, sqlite3
+ ini-values: |
+ memory_limit=512M
+ coverage: none
+ tools: composer
+
+ # --- 3. Cache Composer dependencies ------------------------------------
+ - name: Get Composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
+
+ - name: Cache Composer dependencies
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-php-${{ matrix.php }}-${{ matrix.deps }}-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-php-${{ matrix.php }}-${{ matrix.deps }}-composer-
+
+ # --- 4. Install dependencies -------------------------------------------
+ - name: Install Composer dependencies
+ run: |
+ if [ "${{ matrix.deps }}" = "lowest" ]; then
+ composer update --prefer-lowest --no-interaction --no-progress
+ else
+ composer install --no-interaction --no-progress --prefer-dist
+ fi
+
+ # --- 5. Prepare .env for CodeIgniter 4----------------------
+ # name it `.env.github-ci`
+ - name: Prepare .env
+ run: |
+ if [ -f .env.github-ci ]; then
+ cp .env.github-ci .env
+ elif [ -f env ]; then
+ cp env .env
+ fi
+
+ # --- 6. Basic PHP syntax check -----------------------------------------
+ - name: PHP syntax check (lint)
+ run: |
+ find src tests app -type f -name "*.php" 2>/dev/null \
+ | xargs -r -n 1 php -l
+
+ # --- 7. Run unit tests (PHPUnit) ---------------------------------------
+ - name: Run PHPUnit tests
+ run: |
+ if [ -f vendor/bin/phpunit ]; then
+ vendor/bin/phpunit --testdox
+ else
+ echo "PHPUnit not found. Did you require it as a dev dependency?" >&2
+ exit 1
+ fi
+
+ # --- 8. Static analysis (PHPStan) - optional ---------------------------
+ - name: Run PHPStan (if configured)
+ if: ${{ hashFiles('phpstan.neon*') != '' }}
+ run: |
+ if [ -f vendor/bin/phpstan ]; then
+ vendor/bin/phpstan analyse src tests --no-progress
+ fi
+
+ # --- 9. Coding standards (PHPCS) - optional ----------------------------
+ - name: Run PHPCS (if configured)
+ if: ${{ hashFiles('phpcs.xml*') != '' }}
+ run: |
+ if [ -f vendor/bin/phpcs ]; then
+ vendor/bin/phpcs
+ fi
+
+ # --- 10. PHP-CBF (dry-run) - optional -----------------------------
+ - name: Run PHP-CBF (dry-run, if configured)
+ if: ${{ hashFiles('.php-cs-fixer.dist.php', '.php-cs-fixer.php') != '' }}
+ run: |
+ if [ -f vendor/bin/phpcbf ]; then
+ vendor/bin/phpcbf
+ fi
diff --git a/.gitignore b/.gitignore
index 9f11b75..246f6f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,10 @@
.idea/
+vendor/
+var/
+*.cache
+composer.lock
+.DS_Store
+.vscode/
+phpunit
+build/
+phpunit*.xml
\ No newline at end of file
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
new file mode 100644
index 0000000..5860e0e
--- /dev/null
+++ b/.php-cs-fixer.dist.php
@@ -0,0 +1,30 @@
+setParallelConfig(ParallelConfigFactory::detect()) // @TODO 4.0 no need to call this manually
+ ->setRiskyAllowed(false)
+ ->setRules([
+ '@auto' => true
+ ])
+ // 💡 by default, Fixer looks for `*.php` files excluding `./vendor/` - here, you can groom this config
+ ->setFinder(
+ (new Finder())
+ // 💡 root folder to check
+ ->in(__DIR__)
+ // 💡 additional files, eg bin entry file
+ // ->append([__DIR__.'/bin-entry-file'])
+ // 💡 folders to exclude, if any
+ // ->exclude([/* ... */])
+ // 💡 path patterns to exclude, if any
+ // ->notPath([/* ... */])
+ // 💡 extra configs
+ // ->ignoreDotFiles(false) // true by default in v3, false in v4 or future mode
+ // ->ignoreVCSIgnored(true) // true by default
+ )
+;
diff --git a/composer.json b/composer.json
index 71b05a9..b8fdb85 100644
--- a/composer.json
+++ b/composer.json
@@ -8,8 +8,40 @@
"Jide\\Settings\\": "src/"
}
},
+ "autoload-dev": {
+ "psr-4": {
+ "Jide\\Settings\\Tests\\": "tests/"
+ }
+ },
"require": {
- "php": "^8.0",
- "codeigniter4/framework": "^4.0"
- }
+ "php": "^8.2"
+ },
+ "require-dev": {
+ "codeigniter4/framework": "^4.0",
+ "phpunit/phpunit": "^10.0",
+ "phpstan/phpstan": "^1.11",
+ "phpstan/phpstan-phpunit": "^1.4",
+ "squizlabs/php_codesniffer": "^3.9",
+ "friendsofphp/php-cs-fixer": "^3.0",
+ "systemsdk/phpcpd": "^7.0.1"
+ },
+ "scripts": {
+ "test": "phpunit --testdox",
+ "analyse": "phpstan analyse src tests",
+ "deduplicate": "phpcpd src/ tests/",
+ "cs": "phpcs",
+ "cs-fix": "phpcbf",
+ "style": "@cs-fix",
+ "ci": [
+ "@cs",
+ "@deduplicate",
+ "@analyse",
+ "@test"
+ ]
+ },
+ "config": {
+ "sort-packages": true
+ },
+ "minimum-stability": "stable",
+ "prefer-stable": true
}
diff --git a/phpcs.xml b/phpcs.xml
new file mode 100644
index 0000000..34334cf
--- /dev/null
+++ b/phpcs.xml
@@ -0,0 +1,38 @@
+
+
+
+ Coding standard for the CI4 Settings / Feature Flag library.
+ Applies PSR-12 to src/ and tests/ while ignoring vendor and build artifacts.
+
+
+
+ src
+ tests
+
+
+ vendor/*
+ var/*
+ build/*
+ .phpunit.cache/*
+ .phpstan/*
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..6f2260b
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,32 @@
+includes:
+ # Extra rules for PHPUnit test classes (optional but nice)
+ - vendor/phpstan/phpstan-phpunit/extension.neon
+
+parameters:
+ level: 5
+
+ paths:
+ - src
+ - tests
+
+ tmpDir: var/phpstan
+
+ # Ensure Composer autoload is loaded so all classes are known
+ bootstrapFiles:
+ - vendor/autoload.php
+ - vendor/codeigniter4/framework/system/Test/bootstrap.php
+
+ # Some stricter checks that are very useful
+ checkMissingIterableValueType: true
+ checkGenericClassInNonGenericObjectType: true
+ checkMissingVarTagTypehint: true
+ reportUnmatchedIgnoredErrors: true
+
+ # Option to relax PHPDoc certainty
+ treatPhpDocTypesAsCertain: false
+
+ # Prevents CodeIgniter dynamic properties/magic,
+ # from causing PHPStan to scream too much
+ #ignoreErrors:
+ #- '#^Call to an undefined method CodeIgniter\\.*#'
+ #- '#^Access to an undefined property CodeIgniter\\.*#'
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..76b432c
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,46 @@
+
+
+
+
+ tests
+
+
+
+
+
+ src
+
+
+ vendor
+ tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Libraries/Settings.php b/src/Libraries/Settings.php
index 27febdb..89fa51b 100644
--- a/src/Libraries/Settings.php
+++ b/src/Libraries/Settings.php
@@ -26,7 +26,7 @@ public function __construct(?SettingsConfig $config = null, ?SettingModel $model
/**
* Get a setting by key (supports dot notation).
*/
- public function get(string $key, $default = null): mixed
+ public function get(string $key, mixed $default = null): mixed
{
$all = $this->all();
@@ -37,7 +37,6 @@ public function get(string $key, $default = null): mixed
* Set a setting (create or update).
*/
public function set(string $key, mixed $value, ?string $group = null, bool $isFeature = false): bool
-
{
$type = $this->detectType($value);
$value = $this->prepareValue($value, $type);
@@ -211,7 +210,7 @@ protected function prepareValue($value, string $type): string
return match ($type) {
'bool' => $value ? '1' : '0',
'json' => json_encode($value),
- default => (string)$value,
+ default => (string) $value,
};
}
@@ -300,14 +299,14 @@ protected function arrayHas(array $array, string $path): bool
* Set a value into an array using dot path.
* If \$mergeArrays is true and both existing and new values are arrays, they will be merged.
*/
- protected function arraySet(array & $array, string $path, $value, bool $mergeArrays = false): void
+ protected function arraySet(array &$array, string $path, $value, bool $mergeArrays = false): void
{
if ($path === '') {
return;
}
// If no dots just set top-level key
- if ( ! str_contains($path, '.')) {
+ if (! str_contains($path, '.')) {
if ($mergeArrays && isset($array[$path]) && is_array($array[$path]) && is_array($value)) {
$array[$path] = $this->arrayMergeRecursiveDistinct($array[$path], $value);
} else {
@@ -317,7 +316,7 @@ protected function arraySet(array & $array, string $path, $value, bool $mergeArr
}
$segments = explode('.', $path);
- $current =& $array;
+ $current = & $array;
foreach ($segments as $i => $seg) {
if ($i === count($segments) - 1) {
@@ -334,17 +333,17 @@ protected function arraySet(array & $array, string $path, $value, bool $mergeArr
$current[$seg] = [];
}
- $current =& $current[$seg];
+ $current = & $current[$seg];
}
}
- protected function arrayUnset(array & $array, string $path): void
+ protected function arrayUnset(array &$array, string $path): void
{
if ($path === '') {
return;
}
- if ( ! str_contains($path, '.')) {
+ if (! str_contains($path, '.')) {
if (array_key_exists($path, $array)) {
unset($array[$path]);
}
@@ -353,14 +352,14 @@ protected function arrayUnset(array & $array, string $path): void
$segments = explode('.', $path);
$stack = [];
- $current =& $array;
+ $current = & $array;
foreach ($segments as $seg) {
if (!is_array($current) || !array_key_exists($seg, $current)) {
return; // nothing to unset
}
$stack[] = [&$current, $seg];
- $current =& $current[$seg];
+ $current = & $current[$seg];
}
// unset last
diff --git a/src/Models/SettingModel.php b/src/Models/SettingModel.php
index a342d88..fd10c6a 100644
--- a/src/Models/SettingModel.php
+++ b/src/Models/SettingModel.php
@@ -18,5 +18,4 @@ class SettingModel extends Model
'group',
'is_feature',
];
-
}
diff --git a/tests/SettingsTest.php b/tests/SettingsTest.php
index 2e414d4..2672268 100644
--- a/tests/SettingsTest.php
+++ b/tests/SettingsTest.php
@@ -1,143 +1,41 @@
whereKey = $val;
- return $this;
- }
-
- public function select($cols)
- {
- return $this;
- }
-
- public function first()
- {
- if ($this->whereKey === null) {
- return null;
- }
- foreach ($this->rows as $row) {
- if (($row['key'] ?? null) === $this->whereKey) {
- return $row;
- }
- }
- return null;
- }
-
- public function findAll(?int $limit = null, int $offset = 0)
- {
- return $this->rows;
- }
-
- public function insert($data = null, bool $returnID = true)
- {
- $id = $this->rows ? (max(array_column($this->rows, 'id')) + 1) : 1;
- $data['id'] = $id;
- $this->rows[] = $data;
- return $id;
- }
-
- public function update($id = null, $data = null): bool
- {
- foreach ($this->rows as $i => $row) {
- if (($row['id'] ?? null) == $id) {
- $this->rows[$i] = array_merge($row, $data);
- $this->rows[$i]['id'] = $id;
- return true;
- }
- }
- return false;
- }
-
- public function delete($id = null, bool $purge = false): bool
- {
- foreach ($this->rows as $i => $row) {
- if (($row['id'] ?? null) == $id) {
- array_splice($this->rows, $i, 1);
- return true;
- }
- }
- return false;
- }
-
- // Helpers for tests
- public function setRows(array $rows): void
- {
- $this->rows = $rows;
- }
-
- public function getRows(): array
- {
- return $this->rows;
- }
-}
-
-/**
- * Minimal config stub that disables cache for predictable tests.
- */
-class SettingsConfigStub extends SettingsConfig
-{
- public function __construct()
- {
- $this->useCache = false;
- $this->cacheKey = 'settings_test';
- $this->cacheTTL = 60;
- }
-}
-
-/**
- * PHPUnit tests for Jide\Settings\Libraries\Settings
- * File location: app/ThirdParty/ci4-settings/test/SettingsTest.php
- */
final class SettingsTest extends TestCase
{
public function testSetAndGetScalar()
{
$model = new SettingModelStub();
- $config = new SettingsConfigStub();
-
+ $config = new SettingConfigStub();
+
$settings = new Settings($config, $model);
-
+
$ok = $settings->set('app.name', 'My App');
$this->assertTrue($ok, 'set should return true');
-
+
$value = $settings->get('app.name');
$this->assertSame('My App', $value);
}
-
+
//test setting and getting a nested JSON key
public function testSetAndGetNestedJsonKey()
{
$model = new SettingModelStub();
- $config = new SettingsConfigStub();
-
+ $config = new SettingConfigStub();
+
$settings = new Settings($config, $model);
$ok = $settings->set('app.mail.host', 'smtp.example');
$this->assertTrue($ok, 'set should return true for nested key');
-
+
$value = $settings->get('app.mail.host');
$this->assertSame('smtp.example', $value);
-
+
$ok = $settings->set('mail', [
'host' => 'smtp.example.com',
'port' => 587,
@@ -145,20 +43,18 @@ public function testSetAndGetNestedJsonKey()
'username' => 'user@example.com',
'password' => 'secret',
]);
-
+
$this->assertTrue($ok, 'set should return true for nested key');
-
+
$value = $settings->get('mail.host');
$this->assertSame('smtp.example.com', $value);
-
-
}
-
+
public function testDeleteNestedJsonKeyUpdatesParent()
{
$model = new SettingModelStub();
- $config = new SettingsConfigStub();
-
+ $config = new SettingConfigStub();
+
// seed a parent JSON row
$model->setRows([
[
@@ -168,28 +64,28 @@ public function testDeleteNestedJsonKeyUpdatesParent()
'type' => 'json',
],
]);
-
+
$settings = new Settings($config, $model);
-
+
// delete nested key
$deleted = $settings->delete('app.mail.host');
$this->assertTrue($deleted, 'delete should return true for nested removal');
-
+
$rows = $model->getRows();
$this->assertCount(1, $rows, 'parent row should remain (with updated JSON)');
$parent = $rows[0];
-
+
$decoded = json_decode($parent['value'], true);
$this->assertArrayNotHasKey('host', $decoded);
$this->assertSame('noreply', $decoded['user']);
$this->assertSame('json', $parent['type']);
}
-
+
public function testFeatureEnabledReadsBool()
{
$model = new SettingModelStub();
- $config = new SettingsConfigStub();
-
+ $config = new SettingConfigStub();
+
$model->setRows([
[
'id' => 1,
@@ -198,9 +94,9 @@ public function testFeatureEnabledReadsBool()
'type' => 'bool',
],
]);
-
+
$settings = new Settings($config, $model);
-
+
$this->assertTrue($settings->featureEnabled('coolFeature'));
$this->assertTrue($settings->featureEnabled('feature.coolFeature'));
$this->assertFalse($settings->featureEnabled('nonexistent', false));
diff --git a/tests/Stubs/SettingConfigStub.php b/tests/Stubs/SettingConfigStub.php
new file mode 100644
index 0000000..f081de2
--- /dev/null
+++ b/tests/Stubs/SettingConfigStub.php
@@ -0,0 +1,15 @@
+useCache = false;
+ $this->cacheKey = 'settings_test';
+ $this->cacheTTL = 60;
+ }
+}
diff --git a/tests/Stubs/SettingModelStub.php b/tests/Stubs/SettingModelStub.php
new file mode 100644
index 0000000..60b3eb7
--- /dev/null
+++ b/tests/Stubs/SettingModelStub.php
@@ -0,0 +1,87 @@
+whereKey = $val;
+ return $this;
+ }
+
+ public function select($cols)
+ {
+ return $this;
+ }
+
+ public function first()
+ {
+ if ($this->whereKey === null) {
+ return null;
+ }
+ foreach ($this->rows as $row) {
+ if (($row['key'] ?? null) === $this->whereKey) {
+ return $row;
+ }
+ }
+ return null;
+ }
+
+ public function findAll(?int $limit = null, int $offset = 0)
+ {
+ return $this->rows;
+ }
+
+ public function insert($data = null, bool $returnID = true)
+ {
+ $id = $this->rows ? (max(array_column($this->rows, 'id')) + 1) : 1;
+ $data['id'] = $id;
+ $this->rows[] = $data;
+ return $id;
+ }
+
+ public function update($id = null, $data = null): bool
+ {
+ foreach ($this->rows as $i => $row) {
+ if (($row['id'] ?? null) == $id) {
+ $this->rows[$i] = array_merge($row, $data);
+ $this->rows[$i]['id'] = $id;
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public function delete($id = null, bool $purge = false): bool
+ {
+ foreach ($this->rows as $i => $row) {
+ if (($row['id'] ?? null) == $id) {
+ array_splice($this->rows, $i, 1);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // Helpers for tests
+ public function setRows(array $rows): void
+ {
+ $this->rows = $rows;
+ }
+
+ public function getRows(): array
+ {
+ return $this->rows;
+ }
+}