Skip to content

Commit b1f1dbc

Browse files
committed
feature: experiment connection provider api for standalone database URL management
1 parent 1342c4a commit b1f1dbc

9 files changed

Lines changed: 403 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Next
44

5+
* [feature] 🌟 Experimental `ConnectionProvider` for standalone usage where fetching the database connection DSN is delegated to user custom code (#245).
56
* [feature] 🌟 String pattern anonymizer, build complex strings by fetching values from other anonymizers.
67

78
## 2.0.3
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# Standalone connection provider
2+
3+
When dealing with edge case environement where a static database URL cannot
4+
work, the *connection provider* feature may help you.
5+
6+
Basic idea is to delegate the database connection URL string to a custom function.
7+
8+
## Basic example
9+
10+
Use case: you are working in a multi-tenant environment, each client database
11+
has the same database schema, but has different database credentials.
12+
13+
Let's consider you already have a central component you can access using PHP code
14+
which allows you to find a client database connection:
15+
16+
```php
17+
namespace MyApp;
18+
19+
class MyClientInstanceRegistry
20+
{
21+
public static function getDatabaseCredentialsForClient(string $id): array;
22+
}
23+
```
24+
25+
We are making this example simple for educational purpose, but in real life
26+
use cases you probably will have a more complex component initialization process
27+
for this.
28+
29+
And you have the following anonymization configuration:
30+
31+
```yaml
32+
connections:
33+
any_client: pgsql://clientid:clientpassword@somehostname/client1
34+
35+
anonymization:
36+
any_client:
37+
users:
38+
lastname: fr-fr.lastname
39+
```
40+
41+
Your main obstacle here is to give the correct database URL for the client you
42+
with to act upon. Let's consider that your solution is to use an environment
43+
variable for acting upon each database independently, such as:
44+
45+
```sh
46+
CLIENT_ID="client1" vendor/bin/db-tools anonymize
47+
```
48+
49+
Following chapter will guide you throught three different methods to achieve this.
50+
51+
## Implementing the connection provider interface
52+
53+
You can implement the `ConnectionProvider` interface as such:
54+
55+
```php
56+
namespace MyApp\DbToolsBundle;
57+
58+
use MakinaCorpus\DbToolsBundle\Bridge\Standalone\ConnectionProvider;
59+
60+
class MyClientConnectionProvider implements ConnectionProvider
61+
{
62+
#[\Override]
63+
public function createConnectionDsn(string $name): string
64+
{
65+
if (!$clientId = getenv('CLIENT_ID')) {
66+
throw new \RuntimeException("Did you forget to set the CLIENT_ID environment variable?");
67+
}
68+
69+
$credentials = MyClientInstanceRegistry::getDatabaseCredentialsForClient($clientId);
70+
71+
return \sprintf(
72+
'pgsql://%s:%s@%s:%d/%s?some_option=some_value',
73+
\rawurlencode($credentials['username']),
74+
\rawurlencode($credentials['password']),
75+
\rawurlencode($credentials['hostname']),
76+
$credentials['port'],
77+
\rawurlencode($credentials['database']),
78+
);
79+
}
80+
}
81+
```
82+
83+
Then adapt your YAML configuration:
84+
85+
```yaml
86+
connections:
87+
any_client: MyApp\DbToolsBundle\MyClientConnectionProvider
88+
```
89+
90+
## Using an invokable class
91+
92+
You otherwise simply can write any class implenting the `__invoke()` method:
93+
94+
```php
95+
namespace MyApp\DbToolsBundle;
96+
97+
class MyInvokableClientConnectionProvider
98+
{
99+
public function __invoke()
100+
{
101+
if (!$clientId = getenv('CLIENT_ID')) {
102+
throw new \RuntimeException("Did you forget to set the CLIENT_ID environment variable?");
103+
}
104+
105+
$credentials = MyClientInstanceRegistry::getDatabaseCredentialsForClient($clientId);
106+
107+
return \sprintf(
108+
'pgsql://%s:%s@%s:%d/%s?some_option=some_value',
109+
\rawurlencode($credentials['username']),
110+
\rawurlencode($credentials['password']),
111+
\rawurlencode($credentials['hostname']),
112+
$credentials['port'],
113+
\rawurlencode($credentials['database']),
114+
);
115+
}
116+
}
117+
```
118+
119+
Then adapt your YAML configuration:
120+
121+
```yaml
122+
connections:
123+
any_client: MyApp\DbToolsBundle\MyInvokableClientConnectionProvider
124+
```
125+
126+
## Using a class static method
127+
128+
You can use an object static method:
129+
130+
```php
131+
namespace MyApp\SomeNamespace;
132+
133+
class SomeExistingClass
134+
{
135+
// ... your other code (or not).
136+
137+
public static function myDbToolsConnectionProvider()
138+
{
139+
if (!$clientId = getenv('CLIENT_ID')) {
140+
throw new \RuntimeException("Did you forget to set the CLIENT_ID environment variable?");
141+
}
142+
143+
$credentials = MyClientInstanceRegistry::getDatabaseCredentialsForClient($clientId);
144+
145+
return \sprintf(
146+
'pgsql://%s:%s@%s:%d/%s?some_option=some_value',
147+
\rawurlencode($credentials['username']),
148+
\rawurlencode($credentials['password']),
149+
\rawurlencode($credentials['hostname']),
150+
$credentials['port'],
151+
\rawurlencode($credentials['database']),
152+
);
153+
}
154+
}
155+
```
156+
157+
Then adapt your YAML configuration:
158+
159+
```yaml
160+
connections:
161+
any_client: MyApp\SomeNamespace\SomeExistingClass::myDbToolsConnectionProvider
162+
```
163+
164+
## Using an arbitrary function
165+
166+
Or simply use any PHP function which was loaded using the autoloader:
167+
168+
```php
169+
function my_dbtools_client_connection_provider(): string
170+
{
171+
if (!$clientId = getenv('CLIENT_ID')) {
172+
throw new \RuntimeException("Did you forget to set the CLIENT_ID environment variable?");
173+
}
174+
175+
$credentials = MyClientInstanceRegistry::getDatabaseCredentialsForClient($clientId);
176+
177+
return \sprintf(
178+
'pgsql://%s:%s@%s:%d/%s?some_option=some_value',
179+
\rawurlencode($credentials['username']),
180+
\rawurlencode($credentials['password']),
181+
\rawurlencode($credentials['hostname']),
182+
$credentials['port'],
183+
\rawurlencode($credentials['database']),
184+
);
185+
}
186+
```
187+
188+
Then adapt your YAML configuration:
189+
190+
```yaml
191+
connections:
192+
any_client: my_dbtools_client_connection_provider
193+
```
194+
195+
## Notes
196+
197+
In all cases, the method signature function is always the same `connection_provider(string $name): string`.
198+
199+
You may omit the `$name` parameter if you don't intend to use it. It contains
200+
the connection name as specified in the YAML configuration `connections` value.

docs/content/configuration/reference.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,13 @@ connections: "pgsql://username:password@hostname:port/database?version=16.0&othe
434434
If you configure this parameter with a single URL string with no connection name,
435435
the connection name will be `default`.
436436
:::
437+
438+
:::tip
439+
If you cannot write a static database connection URL, the standalone configuration
440+
file allows you to delegate the connection URL creation to a PHP function.
441+
442+
See [standalone connection provider documentation](./connection_provider) for more information.
443+
:::
437444
@@@
438445

439446
:::tip
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MakinaCorpus\DbToolsBundle\Bridge\Standalone;
6+
7+
/**
8+
* Allow user to plug into the database session creation.
9+
*
10+
* This is intented for edge cases, such as multi-tenant architectures for
11+
* example where you would like to be able to change the database URL using
12+
* environment variables for example.
13+
*
14+
* As of now, this is intended to use in standalone flavor only, in most
15+
* cases when integrating with a framework such as Symfony or Laravel, the
16+
* framework will fully handle the database connections by itself.
17+
*/
18+
interface ConnectionProvider
19+
{
20+
/**
21+
* Dynamically create the database DSN, such as:
22+
* "pgsql://username:password@hostname:port/database?version=16.0"
23+
*
24+
* If you handle a single connection using your implementation, you may
25+
* skip entirely using the $name parameter and simply return the DSN.
26+
*
27+
* If you manage more than one connection and $name is erroneous or
28+
* unknown, you are free to raise any exception, this will force the
29+
* process to stop.
30+
*/
31+
public function createConnectionDsn(string $name): string;
32+
}

src/Bridge/Standalone/StandaloneDatabaseSessionRegistry.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,26 @@ public function getDatabaseSession(string $name): DatabaseSession
5656

5757
protected function getConnectionUri(string $name): string
5858
{
59-
return $this->connections[$name] ?? throw new ConfigurationException(\sprintf("'%s': connection does not exist.", $name));
59+
$value = $this->connections[$name] ?? throw new ConfigurationException(\sprintf("'%s': connection does not exist.", $name));
60+
61+
$callable = null;
62+
63+
if (\is_callable($value)) {
64+
$callable = \Closure::fromCallable($value);
65+
} else if (\class_exists($value)) {
66+
$object = new $value();
67+
68+
if (\is_callable($object)) {
69+
// Class implements __invoke().
70+
$callable = $object;
71+
} else {
72+
if (!$object instanceof ConnectionProvider) {
73+
throw new ConfigurationException(\sprintf("'%s': connection provider object must either implement %s or the __invoke() method.", $name, ConnectionProvider::class));
74+
}
75+
$callable = $object->createConnectionDsn(...);
76+
}
77+
}
78+
79+
return $callable ? $callable($name) : $value;
6080
}
6181
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MakinaCorpus\DbToolsBundle\Tests\Mock;
6+
7+
class ConnectionProviderInvokable
8+
{
9+
public function __invoke()
10+
{
11+
return 'vendor://user1:pass1@host1:2345/db1?opt1=val1';
12+
}
13+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MakinaCorpus\DbToolsBundle\Tests\Mock;
6+
7+
use MakinaCorpus\DbToolsBundle\Bridge\Standalone\ConnectionProvider;
8+
9+
class ConnectionProviderNormal implements ConnectionProvider
10+
{
11+
public function createConnectionDsn(string $name): string
12+
{
13+
return 'vendor://user3:pass3@host3:2345/db3?opt3=' . $name;
14+
}
15+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MakinaCorpus\DbToolsBundle\Tests\Mock;
6+
7+
class ConnectionProviderStatic
8+
{
9+
public static function createConnectionDsn(string $name): string
10+
{
11+
return 'vendor://user5:pass5@host5:2345/db5?opt5=' . $name;
12+
}
13+
}

0 commit comments

Comments
 (0)