Skip to content

Commit 78820ea

Browse files
ajcerezofabpot
authored andcommitted
[Cache] Add CouchbaseCollectionAdapter compatibility with sdk 3.0.0
1 parent c933b85 commit 78820ea

File tree

5 files changed

+305
-2
lines changed

5 files changed

+305
-2
lines changed

.github/workflows/psalm.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
uses: shivammathur/setup-php@v2
1818
with:
1919
php-version: '8.0'
20-
extensions: "json,memcached,mongodb,redis,xsl,ldap,dom"
20+
extensions: "json,couchbase,memcached,mongodb,redis,xsl,ldap,dom"
2121
ini-values: "memory_limit=-1"
2222
coverage: none
2323

src/Symfony/Component/Cache/Adapter/AbstractAdapter.php

+5-1
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,11 @@ public static function createConnection(string $dsn, array $options = [])
133133
return MemcachedAdapter::createConnection($dsn, $options);
134134
}
135135
if (0 === strpos($dsn, 'couchbase:')) {
136-
return CouchbaseBucketAdapter::createConnection($dsn, $options);
136+
if (CouchbaseBucketAdapter::isSupported()) {
137+
return CouchbaseBucketAdapter::createConnection($dsn, $options);
138+
}
139+
140+
return CouchbaseCollectionAdapter::createConnection($dsn, $options);
137141
}
138142

139143
throw new InvalidArgumentException(sprintf('Unsupported DSN: "%s".', $dsn));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Cache\Adapter;
13+
14+
use Couchbase\Bucket;
15+
use Couchbase\Cluster;
16+
use Couchbase\ClusterOptions;
17+
use Couchbase\Collection;
18+
use Couchbase\DocumentNotFoundException;
19+
use Couchbase\UpsertOptions;
20+
use Symfony\Component\Cache\Exception\CacheException;
21+
use Symfony\Component\Cache\Exception\InvalidArgumentException;
22+
use Symfony\Component\Cache\Marshaller\DefaultMarshaller;
23+
use Symfony\Component\Cache\Marshaller\MarshallerInterface;
24+
25+
/**
26+
* @author Antonio Jose Cerezo Aranda <aj.cerezo@gmail.com>
27+
*/
28+
class CouchbaseCollectionAdapter extends AbstractAdapter
29+
{
30+
private const THIRTY_DAYS_IN_SECONDS = 2592000;
31+
private const MAX_KEY_LENGTH = 250;
32+
33+
/** @var Collection */
34+
private $connection;
35+
private $marshaller;
36+
37+
public function __construct(Collection $connection, string $namespace = '', int $defaultLifetime = 0, MarshallerInterface $marshaller = null)
38+
{
39+
if (!static::isSupported()) {
40+
throw new CacheException('Couchbase >= 3.0.0 < 4.0.0 is required.');
41+
}
42+
43+
$this->maxIdLength = static::MAX_KEY_LENGTH;
44+
45+
$this->connection = $connection;
46+
47+
parent::__construct($namespace, $defaultLifetime);
48+
$this->enableVersioning();
49+
$this->marshaller = $marshaller ?? new DefaultMarshaller();
50+
}
51+
52+
/**
53+
* @param array|string $dsn
54+
*
55+
* @return Bucket|Collection
56+
*/
57+
public static function createConnection($dsn, array $options = [])
58+
{
59+
if (\is_string($dsn)) {
60+
$dsn = [$dsn];
61+
} elseif (!\is_array($dsn)) {
62+
throw new \TypeError(sprintf('Argument 1 passed to "%s()" must be array or string, "%s" given.', __METHOD__, get_debug_type($dsn)));
63+
}
64+
65+
if (!static::isSupported()) {
66+
throw new CacheException('Couchbase >= 3.0.0 < 4.0.0 is required.');
67+
}
68+
69+
set_error_handler(function ($type, $msg, $file, $line): bool { throw new \ErrorException($msg, 0, $type, $file, $line); });
70+
71+
$dsnPattern = '/^(?<protocol>couchbase(?:s)?)\:\/\/(?:(?<username>[^\:]+)\:(?<password>[^\@]{6,})@)?'
72+
.'(?<host>[^\:]+(?:\:\d+)?)(?:\/(?<bucketName>[^\/\?]+))(?:(?:\/(?<scopeName>[^\/]+))'
73+
.'(?:\/(?<collectionName>[^\/\?]+)))?(?:\/)?(?:\?(?<options>.*))?$/i';
74+
75+
$newServers = [];
76+
$protocol = 'couchbase';
77+
try {
78+
$username = $options['username'] ?? '';
79+
$password = $options['password'] ?? '';
80+
81+
foreach ($dsn as $server) {
82+
if (0 !== strpos($server, 'couchbase:')) {
83+
throw new InvalidArgumentException(sprintf('Invalid Couchbase DSN: "%s" does not start with "couchbase:".', $server));
84+
}
85+
86+
preg_match($dsnPattern, $server, $matches);
87+
88+
$username = $matches['username'] ?: $username;
89+
$password = $matches['password'] ?: $password;
90+
$protocol = $matches['protocol'] ?: $protocol;
91+
92+
if (isset($matches['options'])) {
93+
$optionsInDsn = self::getOptions($matches['options']);
94+
95+
foreach ($optionsInDsn as $parameter => $value) {
96+
$options[$parameter] = $value;
97+
}
98+
}
99+
100+
$newServers[] = $matches['host'];
101+
}
102+
103+
$option = isset($matches['options']) ? '?'.$matches['options'] : '';
104+
$connectionString = $protocol.'://'.implode(',', $newServers).$option;
105+
106+
$clusterOptions = new ClusterOptions();
107+
$clusterOptions->credentials($username, $password);
108+
109+
$client = new Cluster($connectionString, $clusterOptions);
110+
111+
$bucket = $client->bucket($matches['bucketName']);
112+
$collection = $bucket->defaultCollection();
113+
if (!empty($matches['scopeName'])) {
114+
$scope = $bucket->scope($matches['scopeName']);
115+
$collection = $scope->collection($matches['collectionName']);
116+
}
117+
118+
return $collection;
119+
} finally {
120+
restore_error_handler();
121+
}
122+
}
123+
124+
public static function isSupported(): bool
125+
{
126+
return \extension_loaded('couchbase') && version_compare(phpversion('couchbase'), '3.0', '>=') && version_compare(phpversion('couchbase'), '4.0', '<');
127+
}
128+
129+
private static function getOptions(string $options): array
130+
{
131+
$results = [];
132+
$optionsInArray = explode('&', $options);
133+
134+
foreach ($optionsInArray as $option) {
135+
[$key, $value] = explode('=', $option);
136+
137+
$results[$key] = $value;
138+
}
139+
140+
return $results;
141+
}
142+
143+
/**
144+
* {@inheritdoc}
145+
*/
146+
protected function doFetch(array $ids): array
147+
{
148+
$results = [];
149+
foreach ($ids as $id) {
150+
try {
151+
$resultCouchbase = $this->connection->get($id);
152+
} catch (DocumentNotFoundException $exception) {
153+
continue;
154+
}
155+
156+
$content = $resultCouchbase->value ?? $resultCouchbase->content();
157+
158+
$results[$id] = $this->marshaller->unmarshall($content);
159+
}
160+
161+
return $results;
162+
}
163+
164+
/**
165+
* {@inheritdoc}
166+
*/
167+
protected function doHave($id): bool
168+
{
169+
return $this->connection->exists($id)->exists();
170+
}
171+
172+
/**
173+
* {@inheritdoc}
174+
*/
175+
protected function doClear($namespace): bool
176+
{
177+
return false;
178+
}
179+
180+
/**
181+
* {@inheritdoc}
182+
*/
183+
protected function doDelete(array $ids): bool
184+
{
185+
$idsErrors = [];
186+
foreach ($ids as $id) {
187+
try {
188+
$result = $this->connection->remove($id);
189+
190+
if (null === $result->mutationToken()) {
191+
$idsErrors[] = $id;
192+
}
193+
} catch (DocumentNotFoundException $exception) {
194+
}
195+
}
196+
197+
return 0 === \count($idsErrors);
198+
}
199+
200+
/**
201+
* {@inheritdoc}
202+
*/
203+
protected function doSave(array $values, $lifetime)
204+
{
205+
if (!$values = $this->marshaller->marshall($values, $failed)) {
206+
return $failed;
207+
}
208+
209+
$lifetime = $this->normalizeExpiry($lifetime);
210+
$upsertOptions = new UpsertOptions();
211+
$upsertOptions->expiry($lifetime);
212+
213+
$ko = [];
214+
foreach ($values as $key => $value) {
215+
try {
216+
$this->connection->upsert($key, $value, $upsertOptions);
217+
} catch (\Exception $exception) {
218+
$ko[$key] = '';
219+
}
220+
}
221+
222+
return [] === $ko ? true : $ko;
223+
}
224+
225+
private function normalizeExpiry(int $expiry): int
226+
{
227+
if ($expiry && $expiry > static::THIRTY_DAYS_IN_SECONDS) {
228+
$expiry += time();
229+
}
230+
231+
return $expiry;
232+
}
233+
}

src/Symfony/Component/Cache/LockRegistry.php

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ final class LockRegistry
4040
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'ArrayAdapter.php',
4141
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'ChainAdapter.php',
4242
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'CouchbaseBucketAdapter.php',
43+
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'CouchbaseCollectionAdapter.php',
4344
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'DoctrineAdapter.php',
4445
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'FilesystemAdapter.php',
4546
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'FilesystemTagAwareAdapter.php',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Cache\Tests\Adapter;
13+
14+
use Couchbase\Collection;
15+
use Psr\Cache\CacheItemPoolInterface;
16+
use Symfony\Component\Cache\Adapter\AbstractAdapter;
17+
use Symfony\Component\Cache\Adapter\CouchbaseCollectionAdapter;
18+
19+
/**
20+
* @requires extension couchbase <4.0.0
21+
* @requires extension couchbase >=3.0.0
22+
* @group integration
23+
*
24+
* @author Antonio Jose Cerezo Aranda <aj.cerezo@gmail.com>
25+
*/
26+
class CouchbaseCollectionAdapterTest extends AdapterTestCase
27+
{
28+
protected $skippedTests = [
29+
'testClearPrefix' => 'Couchbase cannot clear by prefix',
30+
];
31+
32+
/** @var Collection */
33+
protected static $client;
34+
35+
public static function setupBeforeClass(): void
36+
{
37+
if (!CouchbaseCollectionAdapter::isSupported()) {
38+
self::markTestSkipped('Couchbase >= 3.0.0 < 4.0.0 is required.');
39+
}
40+
41+
self::$client = AbstractAdapter::createConnection('couchbase://'.getenv('COUCHBASE_HOST').'/cache',
42+
['username' => getenv('COUCHBASE_USER'), 'password' => getenv('COUCHBASE_PASS')]
43+
);
44+
}
45+
46+
/**
47+
* {@inheritdoc}
48+
*/
49+
public function createCachePool($defaultLifetime = 0): CacheItemPoolInterface
50+
{
51+
if (!CouchbaseCollectionAdapter::isSupported()) {
52+
self::markTestSkipped('Couchbase >= 3.0.0 < 4.0.0 is required.');
53+
}
54+
55+
$client = $defaultLifetime
56+
? AbstractAdapter::createConnection('couchbase://'
57+
.getenv('COUCHBASE_USER')
58+
.':'.getenv('COUCHBASE_PASS')
59+
.'@'.getenv('COUCHBASE_HOST')
60+
.'/cache')
61+
: self::$client;
62+
63+
return new CouchbaseCollectionAdapter($client, str_replace('\\', '.', __CLASS__), $defaultLifetime);
64+
}
65+
}

0 commit comments

Comments
 (0)