1 /**
2  * ae.sys.persistence.keyvalue
3  *
4  * License:
5  *   This Source Code Form is subject to the terms of
6  *   the Mozilla Public License, v. 2.0. If a copy of
7  *   the MPL was not distributed with this file, You
8  *   can obtain one at http://mozilla.org/MPL/2.0/.
9  *
10  * Authors:
11  *   Vladimir Panteleev <vladimir@thecybershadow.net>
12  */
13 
14 module ae.sys.persistence.keyvalue;
15 
16 import std.exception;
17 import std.traits;
18 
19 import ae.sys.persistence.core;
20 import ae.sys.sqlite3;
21 import ae.utils.json;
22 
23 // ****************************************************************************
24 
25 /// Persistent indexed key-value store, backed by an SQLite database.
26 /// Non-string keys/values are JSON-encoded.
27 struct KeyValueStore(K, V)
28 {
29 	KeyValueDatabase* db;
30 	string tableName;
31 
32 	this(KeyValueDatabase* db, string tableName = "values")
33 	{
34 		this.db = db;
35 		this.tableName = tableName;
36 	}
37 
38 	this(string fn)
39 	{
40 		auto db = new KeyValueDatabase(fn);
41 		this(db);
42 	}
43 
44 	V opIndex()(auto ref K k)
45 	{
46 		checkInitialized();
47 		foreach (SqlType!V v; sqlGet.iterate(toSqlType(k)))
48 			return fromSqlType!V(v);
49 		throw new Exception("Value not in KeyValueStore");
50 	}
51 
52 	V get()(auto ref K k, auto ref V defaultValue)
53 	{
54 		checkInitialized();
55 		foreach (SqlType!V v; sqlGet.iterate(toSqlType(k)))
56 			return fromSqlType!V(v);
57 		return defaultValue;
58 	}
59 
60 	V getOrAdd()(auto ref K k, lazy V defaultValue)
61 	{
62 		checkInitialized();
63 		foreach (SqlType!V v; sqlGet.iterate(toSqlType(k)))
64 			return fromSqlType!V(v);
65 		auto v = defaultValue();
66 		sqlSet.exec(toSqlType(k), toSqlType(v));
67 		return v;
68 	}
69 
70 	bool opBinaryRight(string op)(auto ref K k)
71 	if (op == "in")
72 	{
73 		checkInitialized();
74 		foreach (int count; sqlExists.iterate(toSqlType(k)))
75 			return count > 0;
76 		assert(false);
77 	}
78 
79 	auto ref V opIndexAssign()(auto ref V v, auto ref K k)
80 	{
81 		checkInitialized();
82 		sqlSet.exec(toSqlType(k), toSqlType(v));
83 		return v;
84 	}
85 
86 	void remove()(auto ref K k)
87 	{
88 		checkInitialized();
89 		sqlDelete.exec(toSqlType(k));
90 	}
91 
92 	@property int length()
93 	{
94 		checkInitialized();
95 		foreach (int count; sqlLength.iterate())
96 			return count;
97 		assert(false);
98 	}
99 
100 private:
101 	static SqlType!T toSqlType(T)(auto ref T v)
102 	{
103 		alias S = SqlType!T;
104 		static if (is(T : long)) // long
105 			return v;
106 		else
107 		static if (is(T : const(char)[])) // string
108 			return cast(S) v;
109 		else
110 		static if (is(T U : U[]) && !hasIndirections!U) // void[]
111 			return v;
112 		else
113 			return toJson(v);
114 	}
115 
116 	static T fromSqlType(T)(SqlType!T v)
117 	{
118 		static if (is(T : long)) // long
119 			return cast(T) v;
120 		else
121 		static if (is(T : const(char)[])) // string
122 			return cast(T) v;
123 		else
124 		static if (is(T U : U[]) && !hasIndirections!U) // void[]
125 			static if (is(T V : V[N], size_t N))
126 			{
127 				assert(v.length == N, "Static array length mismatch");
128 				return cast(T) v[0..N];
129 			}
130 			else
131 				return cast(T) v;
132 		else
133 			return jsonParse!T(cast(string) v);
134 	}
135 
136 	template SqlType(T)
137 	{
138 		static if (is(T : long))
139 			alias SqlType = long;
140 		else
141 		static if (is(T : const(char)[]))
142 			alias SqlType = string;
143 		else
144 		static if (is(T U : U[]) && !hasIndirections!U)
145 			alias SqlType = void[];
146 		else
147 			alias SqlType = string; // JSON-encoded
148 	}
149 
150 	static assert(is(SqlType!int == long));
151 	static assert(is(SqlType!string == string));
152 
153 	template sqlTypeName(T)
154 	{
155 		alias S = SqlType!T;
156 		static if (is(S == long))
157 			enum sqlTypeName = "INTEGER";
158 		else
159 		static if (is(S == string))
160 			enum sqlTypeName = "TEXT";
161 		else
162 		static if (is(S == void[]))
163 			enum sqlTypeName = "BLOB";
164 		else
165 			enum sqlTypeName = "TEXT"; // JSON
166 	}
167 
168 	bool initialized;
169 
170 	SQLite.PreparedStatement sqlGet, sqlSet, sqlDelete, sqlExists, sqlLength;
171 
172 	void checkInitialized()
173 	{
174 		if (!initialized)
175 		{
176 			assert(db, "KeyValueStore database not set");
177 			db.exec("CREATE TABLE IF NOT EXISTS [" ~ tableName ~ "] ([key] " ~ sqlTypeName!K ~ " PRIMARY KEY, [value] " ~ sqlTypeName!V ~ ")");
178 			db.exec("PRAGMA SYNCHRONOUS=OFF");
179 			sqlGet = db.prepare("SELECT [value] FROM [" ~ tableName ~ "] WHERE [key]=?");
180 			sqlSet = db.prepare("INSERT OR REPLACE INTO [" ~ tableName ~ "] VALUES (?, ?)");
181 			sqlDelete = db.prepare("DELETE FROM [" ~ tableName ~ "] WHERE [key]=?");
182 			sqlExists = db.prepare("SELECT COUNT(*) FROM [" ~ tableName ~ "] WHERE [key]=? LIMIT 1");
183 			sqlLength = db.prepare("SELECT COUNT(*) FROM [" ~ tableName ~ "]");
184 			initialized = true;
185 		}
186 	}
187 }
188 
189 struct KeyValueDatabase
190 {
191 	string fileName;
192 
193 	SQLite sqlite;
194 
195 	@property SQLite getSQLite()
196 	{
197 		if (sqlite is null)
198 		{
199 			enforce(fileName, "KeyValueDatabase filename not set");
200 			sqlite = new SQLite(fileName);
201 		}
202 		return sqlite;
203 	}
204 
205 	alias getSQLite this;
206 }
207 
208 unittest
209 {
210 	import std.file;
211 
212 	string fn = tempDir ~ "/ae-sys-persistence-keyvalue-test.s3db";
213 	if (fn.exists) fn.remove();
214 	//scope(exit) if (fn.exists) fn.remove();
215 	auto store = KeyValueStore!(string, string)(fn);
216 
217 	assert(store.length == 0);
218 	assert("key" !in store);
219 	assert(store.get("key", null) is null);
220 
221 	store["key"] = "value";
222 
223 	assert(store.length == 1);
224 	assert("key" in store);
225 	assert(store["key"] == "value");
226 	assert(store.get("key", null) == "value");
227 
228 	store["key"] = "value2";
229 
230 	assert(store.length == 1);
231 	assert("key" in store);
232 	assert(store.get("key", null) == "value2");
233 
234 	store["key2"] = "value3";
235 
236 	assert(store.length == 2);
237 	assert("key" in store);
238 	assert("key2" in store);
239 	assert(store.get("key", null) == "value2");
240 	assert(store.get("key2", null) == "value3");
241 
242 	store.remove("key");
243 
244 	assert(store.length == 1);
245 	assert("key" !in store);
246 	assert("key2" in store);
247 	assert(store.get("key", null) is null);
248 }
249 
250 unittest
251 {
252 	if (false)
253 	{
254 		KeyValueStore!(string, ubyte[20]) kv;
255 		ubyte[20] s = kv[""];
256 	}
257 }