1<?php
2
3namespace Doctrine\Common\Cache;
4
5use SQLite3;
6use SQLite3Result;
7use const SQLITE3_ASSOC;
8use const SQLITE3_BLOB;
9use const SQLITE3_TEXT;
10use function array_search;
11use function implode;
12use function serialize;
13use function sprintf;
14use function time;
15use function unserialize;
16
17/**
18 * SQLite3 cache provider.
19 */
20class SQLite3Cache extends CacheProvider
21{
22    /**
23     * The ID field will store the cache key.
24     */
25    public const ID_FIELD = 'k';
26
27    /**
28     * The data field will store the serialized PHP value.
29     */
30    public const DATA_FIELD = 'd';
31
32    /**
33     * The expiration field will store a date value indicating when the
34     * cache entry should expire.
35     */
36    public const EXPIRATION_FIELD = 'e';
37
38    /** @var SQLite3 */
39    private $sqlite;
40
41    /** @var string */
42    private $table;
43
44    /**
45     * Calling the constructor will ensure that the database file and table
46     * exist and will create both if they don't.
47     *
48     * @param string $table
49     */
50    public function __construct(SQLite3 $sqlite, $table)
51    {
52        $this->sqlite = $sqlite;
53        $this->table  = (string) $table;
54
55        $this->ensureTableExists();
56    }
57
58    private function ensureTableExists() : void
59    {
60        $this->sqlite->exec(
61            sprintf(
62                'CREATE TABLE IF NOT EXISTS %s(%s TEXT PRIMARY KEY NOT NULL, %s BLOB, %s INTEGER)',
63                $this->table,
64                static::ID_FIELD,
65                static::DATA_FIELD,
66                static::EXPIRATION_FIELD
67            )
68        );
69    }
70
71    /**
72     * {@inheritdoc}
73     */
74    protected function doFetch($id)
75    {
76        $item = $this->findById($id);
77
78        if (! $item) {
79            return false;
80        }
81
82        return unserialize($item[self::DATA_FIELD]);
83    }
84
85    /**
86     * {@inheritdoc}
87     */
88    protected function doContains($id)
89    {
90        return $this->findById($id, false) !== null;
91    }
92
93    /**
94     * {@inheritdoc}
95     */
96    protected function doSave($id, $data, $lifeTime = 0)
97    {
98        $statement = $this->sqlite->prepare(sprintf(
99            'INSERT OR REPLACE INTO %s (%s) VALUES (:id, :data, :expire)',
100            $this->table,
101            implode(',', $this->getFields())
102        ));
103
104        $statement->bindValue(':id', $id);
105        $statement->bindValue(':data', serialize($data), SQLITE3_BLOB);
106        $statement->bindValue(':expire', $lifeTime > 0 ? time() + $lifeTime : null);
107
108        return $statement->execute() instanceof SQLite3Result;
109    }
110
111    /**
112     * {@inheritdoc}
113     */
114    protected function doDelete($id)
115    {
116        [$idField] = $this->getFields();
117
118        $statement = $this->sqlite->prepare(sprintf(
119            'DELETE FROM %s WHERE %s = :id',
120            $this->table,
121            $idField
122        ));
123
124        $statement->bindValue(':id', $id);
125
126        return $statement->execute() instanceof SQLite3Result;
127    }
128
129    /**
130     * {@inheritdoc}
131     */
132    protected function doFlush()
133    {
134        return $this->sqlite->exec(sprintf('DELETE FROM %s', $this->table));
135    }
136
137    /**
138     * {@inheritdoc}
139     */
140    protected function doGetStats()
141    {
142        // no-op.
143    }
144
145    /**
146     * Find a single row by ID.
147     *
148     * @param mixed $id
149     *
150     * @return array|null
151     */
152    private function findById($id, bool $includeData = true) : ?array
153    {
154        [$idField] = $fields = $this->getFields();
155
156        if (! $includeData) {
157            $key = array_search(static::DATA_FIELD, $fields);
158            unset($fields[$key]);
159        }
160
161        $statement = $this->sqlite->prepare(sprintf(
162            'SELECT %s FROM %s WHERE %s = :id LIMIT 1',
163            implode(',', $fields),
164            $this->table,
165            $idField
166        ));
167
168        $statement->bindValue(':id', $id, SQLITE3_TEXT);
169
170        $item = $statement->execute()->fetchArray(SQLITE3_ASSOC);
171
172        if ($item === false) {
173            return null;
174        }
175
176        if ($this->isExpired($item)) {
177            $this->doDelete($id);
178
179            return null;
180        }
181
182        return $item;
183    }
184
185    /**
186     * Gets an array of the fields in our table.
187     *
188     * @return array
189     */
190    private function getFields() : array
191    {
192        return [static::ID_FIELD, static::DATA_FIELD, static::EXPIRATION_FIELD];
193    }
194
195    /**
196     * Check if the item is expired.
197     *
198     * @param array $item
199     */
200    private function isExpired(array $item) : bool
201    {
202        return isset($item[static::EXPIRATION_FIELD]) &&
203            $item[self::EXPIRATION_FIELD] !== null &&
204            $item[self::EXPIRATION_FIELD] < time();
205    }
206}
207