diff --git a/README.md b/README.md index 9809d1d..e73eaec 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ A tiny, fully featured dependency injection container as a DSL. ## Table of contents -- [Contributing](CONTRIBUTING.md) -- [Feature Guide](docs/README.md) +- [Feature Guide](docs/README.md) - Full feature guide. +- [INI DSL](docs/DSL.md) - Support for .ini files. - [Installation](docs/INSTALL.md) +- [Contributing](CONTRIBUTING.md) - [License](LICENSE.md) -- [What is a DSL?](docs/DSL.md) diff --git a/docs/DSL.md b/docs/DSL.md index 9ba5355..9f68dc7 100644 --- a/docs/DSL.md +++ b/docs/DSL.md @@ -1,8 +1,281 @@ -# What is a DSL? +# INI DSL Guide -DSLs are Domain Specific Languages, small languages implemented for specific -domains. Respect\Config is an **internal DSL** hosted on the INI format to -hold dependency injection containers. +Respect\Config extends the standard INI format with a DSL for declaring dependency +injection containers. Everything in this guide maps to the PHP API described in the +[Feature Guide](README.md) — if you haven't read that first, start there. + +## Loading INI + +```php +use Respect\Config\IniLoader; + +// From a file +$container = IniLoader::load('services.ini'); + +// From a string +$container = IniLoader::load('db_host = localhost'); + +// Onto an existing container +IniLoader::load('overrides.ini', $container); +``` + +## Simple Values + +```ini +app_name = "My Application" +per_page = 20 +tax_rate = 0.075 +error_mode = PDO::ERRMODE_EXCEPTION +severity = E_USER_ERROR +``` + +```php +$container->get('per_page'); // 20 (int) +$container->get('tax_rate'); // 0.075 (float) +$container->get('error_mode'); // 2 (PDO::ERRMODE_EXCEPTION) +``` + +## Sequences + +Comma-separated values inside brackets produce PHP arrays: + +```ini +allowed_origins = [http://localhost:8000, http://localhost:3000] +``` + +```php +// ['http://localhost:8000', 'http://localhost:3000'] +$container->get('allowed_origins'); +``` + +## Instances + +Create instances using INI sections. The section name becomes the container key, +the class name follows after a space: + +```ini +[connection PDO] +dsn = "sqlite:app.db" +``` + +```php +$container->get('connection'); // PDO instance +``` + +The `instanceof` keyword uses the class name itself as the container key: + +```ini +[instanceof PDO] +dsn = "sqlite:app.db" +``` + +```php +$container->get(PDO::class); // PDO instance, keyed by class name +``` + +## Constructor Parameters + +Parameter names under a section are matched to the class constructor via reflection: + +```ini +[connection PDO] +dsn = "sqlite:app.db" +username = "root" +password = "secret" +``` + +You can also pass all constructor arguments as a positional list: + +```ini +[connection PDO] +__construct = ["sqlite:app.db", "root", "secret"] +``` + + 1. Set only the parameters you need — unset parameters keep their defaults. + 2. Trailing `null` parameters are automatically stripped so defaults apply. + +## References + +Use `[name]` as a parameter value to reference another container entry. This is +the INI equivalent of passing an `Instantiator` object as a parameter in PHP: + +Given the class: + +```php +class Mapper { + public function __construct(public PDO $db) {} +} +``` + +Wire it with `[name]` references: + +```ini +[connection PDO] +dsn = "sqlite:app.db" + +[mapper Mapper] +db = [connection] +``` + +```php +$container->get('mapper'); // Mapper instance with the PDO connection injected +``` + +References also work inside sequences: + +```ini +admin = admin@example.com +notify = [[admin], ops@example.com] +``` + +```php +$container->get('notify'); // ['admin@example.com', 'ops@example.com'] +``` + +## Method Calls + +Call methods on an instance after construction using `[]` syntax. +Each `methodName[]` entry is one call: + +```ini +[connection PDO] +dsn = "sqlite:app.db" +setAttribute[] = [PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION] +setAttribute[] = [PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC] +exec[] = "PRAGMA journal_mode=WAL" +exec[] = "PRAGMA foreign_keys=ON" +``` + +```php +$container->get('connection'); // PDO with attributes set and PRAGMAs executed +``` + +## Static Factory Methods + +Static methods use the same `[]` syntax. They are detected automatically via +reflection: + +```ini +[y2k DateTime] +createFromFormat[] = [Y-m-d, 2000-01-01] +``` + +```php +$container->get('y2k'); // DateTime for 2000-01-01 +``` + +The `Container` will skip the constructor and use the factory, same as the pure +PHP version. + +## Properties + +Names that don't match a constructor parameter or method are set as public +properties on the instance: + +```php +class Request { + public int $timeout = 10; + public string $base_url = ''; +} +``` + +```ini +[request Request] +timeout = 30 +base_url = "https://api.example.com" +``` + +```php +$container->get('request')->base_url; // 'https://api.example.com' +``` + +The resolution order is: constructor parameter → static method → instance method → property. + +## Autowiring + +Use the `autowire` modifier to enable automatic type-hint resolution: + +```php +class UserRepository { + public bool $cacheEnabled = false; + public function __construct(public PDO $db) {} +} +``` + +```ini +[connection PDO] +dsn = "sqlite:app.db" + +[repository autowire UserRepository] +``` + +```php +$container->get('repository'); // UserRepository with PDO auto-injected +``` + +The `PDO` instance is injected automatically because the container has an +entry keyed by `PDO`, matching the type hint on the constructor. + +Explicit parameters can be mixed in alongside autowiring: + +```ini +[repository autowire UserRepository] +cacheEnabled = true +``` + +## Factory (Fresh Instances) + +Use the `new` modifier to create a fresh instance on every access: + +```php +class PostController { + public function __construct(public Mapper $mapper) {} +} +``` + +```ini +[controller new PostController] +mapper = [mapper] +``` + +```php +$a = $container->get('controller'); // new PostController +$b = $container->get('controller'); // another new PostController +assert($a !== $b); +``` +Dependencies like `[mapper]` are still resolved and cached normally. + +## String Interpolation + +The `[name]` placeholder syntax can also be used inline within a string to +build composite values. This always produces a string: + +```ini +db_driver = mysql +db_host = localhost +db_name = myapp +db_dsn = "[db_driver]:host=[db_host];dbname=[db_name]" +``` + +```php +$container->get('db_dsn'); // "mysql:host=localhost;dbname=myapp" +``` + +Only root-level simple scalars can be interpolated. + +## State Precedence + +When loading INI onto a pre-populated container, existing non-Instantiator +values take precedence: + +```php +$container = new Container(['env' => 'production']); +IniLoader::load('config.ini', $container); +// If config.ini has env = development, the existing 'production' value wins +``` + +This allows environment-specific values to be set before loading the INI file, +ensuring they are not overwritten. *** diff --git a/docs/README.md b/docs/README.md index f69823c..050d647 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,204 +1,273 @@ # Feature Guide -## Variable Expanding +## Getting Started -myconfig.ini: +Respect\Config is a lightweight dependency injection container. It implements PSR-11 (`ContainerInterface`) and supports lazy loading, autowiring, and factory patterns. -````ini -db_driver = "mysql" -db_host = "localhost" -db_name = "my_database" -db_dsn = "[db_driver]:host=[db_host];dbname=[db_name]" -```` +It also optionally supports container declarations as INI files. -myapp.php: +--- -````php -$c = new Container('myconfig.ini'); -echo $c->db_dsn; //mysql:host=localhost;dbname=my_database -```` +Build a container programmatically: +```php +$container = new Container([ + 'debug' => true, + 'locale' => 'en_US', +]); +echo $container->get('locale'); // en_US +``` -Note that this works only for variables without ini [sections]. +**Alternatively**, you can load configuration from an INI file: +```php +$container = IniLoader::load('services.ini'); // INI path or INI-like string +``` -## Sequences +The choice is yours: pure PHP, or declarative INI containers. +All the features below can also be declared in INI files: see the +[INI DSL Guide](DSL.md) for the full syntax reference. -myconfig.ini: +## Simple Values -````ini -allowed_users = [foo,bar,baz] -```` +Plain values are stored directly in the container: -myapp.php: +```php +$container = new Container([ + 'app_name' => 'My Application', + 'per_page' => 20, + 'tax_rate' => 0.075, +]); +``` -````php -$c = new Container('myconfig.ini'); -print_r($c->allowed_users); //array('foo', 'bar', 'baz') -```` +Values can also be set after construction: -Variable expanding also works on sequences. You can express something like this: +```php +$container['cache_ttl'] = 3600; +$container->set('debug', false); +``` -myconfig.ini: - -````ini -admin_user = foo -allowed_users = [[admin_user],bar,baz] -```` - -myapp.php: - -````php -$c = new Container('myconfig.ini'); -print_r($c->allowed_users); //array('foo', 'bar', 'baz') -```` - -## Constant Evaluation - -myconfig.ini: - -````ini -error_mode = PDO::ERRMODE_EXCEPTION -```` +## Instances -Needless to say that this would work on sequences too. +Use `Instantiator` to register a class that will be instantiated on demand. +Constructor parameters are matched by name via reflection: -## Instances +```php +$container = new Container([ + 'connection' => new Instantiator(PDO::class, [ + 'dsn' => 'sqlite:app.db', // only the parameters you need, by name + ]), +]); -Using sections +$pdo = $container->get('connection'); // PDO, only created when you access +assert($pdo === $container->get('connection')); // accessing again yields same instance +``` -myconfig.ini: +## Instance References -````ini -[something stdClass] -```` +Pass an `Instantiator` as a parameter value to wire services together. +It will be resolved automatically when the parent is instantiated: -myapp.php: +```php +class Mapper { + public function __construct(public PDO $db) {} +} -````php -$c = new Container('myconfig.ini'); -echo get_class($c->something); //stdClass -```` +$connection = new Instantiator(PDO::class, ['dsn' => 'sqlite:app.db']); -Using names +$container = new Container([ + 'connection' => $connection, + 'mapper' => new Instantiator(Mapper::class, [ + 'db' => $connection, + ]), +]); +``` -myconfig.ini: + 1. References are resolved lazily: the referenced service is instantiated only + when the dependent service needs it. + 2. Passing the same `Instantiator` object to multiple consumers ensures + they all share a single instance. -````ini -date DateTime = now -```` +## Method Calls -myapp.php: +Sometimes, you want to call methods to configure the instance you just created. +You can make those into injection parameters as well. -````php -$c = new Container('myconfig.ini'); -echo get_class($c->something); //DateTime -```` +Each inner array represents one call's arguments: -## Callbacks +```php +$connection = new Instantiator(PDO::class, ['dsn' => 'sqlite:app.db']); +$connection->setParam('setAttribute', [ + [PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION], + [PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC], +]); +$connection->setParam('exec', [ + ['PRAGMA journal_mode=WAL'], + ['PRAGMA foreign_keys=ON'], +]); + +$container = new Container(['connection' => $connection]); +$container->get('connection'); // PDO with attributes set and PRAGMAs executed +``` + +## Static Factory Methods + +Some instances require you to invoke a factory method, like +`DateTime::createFromFormat` which returns an instance of `DateTime`. -myconfig.ini: +You can also express those as injection parameters, and the Instantiator +will understand they're a factory when loaded: -````ini -db_driver = "mysql" -db_host = "localhost" -db_name = "my_database" -db_user = "my_user" -db_pass = "my_pass" -db_dsn = "[db_driver]:host=[db_host];dbname=[db_name]" -```` +```php +$y2k = new Instantiator(DateTime::class); +$y2k->setParam('createFromFormat', [['Y-m-d', '2000-01-01']]); +$container = new Container(['y2k' => $y2k]); +$container->get('y2k'); // DateTime for 2000-01-01 +``` + +## Properties + +Names that don't match a constructor parameter or method are set as public +properties on the instance: + +```php +class Request { + public int $timeout = 10; + public string $base_url = ''; +} -myapp.php: +$container = new Container([ + 'request' => new Instantiator(Request::class, [ + 'timeout' => 30, + 'base_url' => 'https://api.example.com', + ]), +]); + +$container->get('request')->base_url; // 'https://api.example.com' +``` -````php -$c = new Container('myconfig.ini'); -$c->connection = function() use($c) { - return new PDO($c->db_dsn, $c->db_user, $c->db_pass); -}; -echo get_class($c->connection); //PDO -```` +The resolution order is: constructor parameter -> static method -> instance method -> property. -## Instance Passing +## Closures -myconfig.ini: +Register closures for full programmatic control. The container is passed as +the argument: + +```php +$container = new Container([ + 'dsn' => 'sqlite:app.db', + 'connection' => function (Container $c) { + $pdo = new PDO($c->get('dsn')); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + return $pdo; + }, +]); -````ini -[myClass DateTime] +$container->get('connection'); // PDO instance +``` -[anotherClass stdClass] -myProperty = [myClass] -```` +## Autowiring + +Autowire resolves constructor dependencies automatically by matching type hints +against the container: -myapp.php: +```php +class UserRepository { + public function __construct(public PDO $db) {} +} -````php -$c = new Container('myconfig.ini'); -echo get_class($c->myClass); //DateTime -echo get_class($c->anotherClass); //stdClass -echo get_class($c->myClass->myProperty); //DateTime -```` +$container = new Container([ + PDO::class => new Instantiator(PDO::class, ['dsn' => 'sqlite:app.db']), + 'repository' => new Autowire(UserRepository::class), +]); +``` -Obviously, this works on sequences too. +The `PDO` instance is injected automatically because the container has an +entry keyed by `PDO`, matching the type hint on the constructor. -## Instance Constructor Parameters +## Ref: Explicit References -Parameter names by reflection: +Use `Ref` to inject a specific container entry into an Autowire parameter: +whether to de-ambiguate instances with the same type, or to wire a plain +value like an array or string: -myconfig.ini: +```php +class UserRepository { + public function __construct(public PDO $db, public array $ignoredPaths) {} +} -````ini -[connection PDO] -dsn = "mysql:host=localhost;dbname=my_database" -username = "my_user" -password = "my_pass" -```` +$container = new Container([ + 'primary_db' => new Instantiator(PDO::class, ['dsn' => 'sqlite:primary.db']), + 'replica_db' => new Instantiator(PDO::class, ['dsn' => 'sqlite:replica.db']), + 'ignored_paths' => ['/var/log', '/tmp'], + 'rule_namespaces' => ['App\\Validators'], + 'repository' => new Autowire(UserRepository::class, [ + 'db' => new Ref('replica_db'), // Ref to de-ambiguate autowiring + 'ignoredPaths' => new Ref('ignored_paths'), // Ref to auto-wire non-class + ]), +]); +``` -Method call by sequence: +`Ref` can only be used with `Autowire`, not with plain `Instantiator`. -myconfig.ini: +## Factory (Fresh Instances) -````ini -[connection PDO] -__construct = ["mysql:host=localhost;dbname=my_database", "my_user", "my_pass"] -```` +By default, instances are cached. Use `Factory` to create a fresh instance on +every access: -Using Names and Sequences: +```php +class PostController { + public function __construct(public Mapper $mapper) {} +} -myconfig.ini: +$container = new Container([ + 'controller' => new Factory(PostController::class, [ + 'mapper' => new Autowire(Mapper::class), + ]), +]); -````ini -connection PDO = ["mysql:host=localhost;dbname=my_database", "my_user", "my_pass"] -```` +$first = $container->get('controller'); // new PostController +$second = $container->get('controller'); // another new PostController +assert($first !== $second); +``` + +## Multiple Config Sources -## Instantiation by Static Factory Methods +`IniLoader` can load from files, strings, or arrays, and can layer configurations +onto an existing container: -myconfig.ini: +```php +$container = new Container(['env' => 'production']); +IniLoader::load('base.ini', $container); +IniLoader::load('overrides.ini', $container); +``` -````ini -[y2k DateTime] -createFromFormat[] = [Y-m-d H:i:s, 2000-01-01 00:00:01] -```` +Existing non-Instantiator values take precedence: if `env` is already set as a +plain value, a subsequent INI load will not overwrite it. -## Instance Method Calls +## Error Handling -myconfig.ini: +The container throws `Respect\Config\NotFoundException` (which implements +`Psr\Container\NotFoundExceptionInterface`) when accessing a missing key: -````ini -[connection PDO] -dsn = "mysql:host=localhost;dbname=my_database" -username = "my_user" -password = "my_pass" -setAttribute = [PDO::ATTR_ERRMODE, PDO::ATTR_EXCEPTION] -exec[] = "SET NAMES UTF-8" -```` +```php +$container->get('nonexistent'); // throws NotFoundException +``` + +Use `has()` to check before accessing: -## Instance Properties +```php +if ($container->has('cache')) { + $cache = $container->get('cache'); +} +``` -myconfig.ini: +Other exceptions that may be thrown: -````ini -[something stdClass] -foo = "bar" -```` + * `InvalidArgumentException`: from `IniLoader` when the input is not a valid + INI file, string, or array; or from `Instantiator` when a `Ref` is used + without `Autowire`. + * `ReflectionException`: wrapped in `NotFoundException` when an Instantiator + cannot reflect on the target class (e.g. class not found). *** @@ -206,6 +275,6 @@ See also: - [Home](../README.md) - [Contributing](../CONTRIBUTING.md) +- [INI DSL Guide](DSL.md) - [Installation](INSTALL.md) - [License](../LICENSE.md) -- [What is a DSL?](DSL.md)