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 opIn_r()(auto ref K k)
71 	{
72 		checkInitialized();
73 		foreach (int count; sqlExists.iterate(toSqlType(k)))
74 			return count > 0;
75 		assert(false);
76 	}
77 
78 	auto ref V opIndexAssign()(auto ref V v, auto ref K k)
79 	{
80 		checkInitialized();
81 		sqlSet.exec(toSqlType(k), toSqlType(v));
82 		return v;
83 	}
84 
85 	void remove()(auto ref K k)
86 	{
87 		checkInitialized();
88 		sqlDelete.exec(toSqlType(k));
89 	}
90 
91 	@property int length()
92 	{
93 		checkInitialized();
94 		foreach (int count; sqlLength.iterate())
95 			return count;
96 		assert(false);
97 	}
98 
99 private:
100 	static SqlType!T toSqlType(T)(auto ref T v)
101 	{
102 		alias S = SqlType!T;
103 		static if (is(T : long)) // long
104 			return v;
105 		else
106 		static if (is(T : const(char)[])) // string
107 			return cast(S) v;
108 		else
109 		static if (is(T U : U[]) && !hasIndirections!U) // void[]
110 			return v;
111 		else
112 			return toJson(v);
113 	}
114 
115 	static T fromSqlType(T)(SqlType!T v)
116 	{
117 		static if (is(T : long)) // long
118 			return cast(T) v;
119 		else
120 		static if (is(T : const(char)[])) // string
121 			return cast(T) v;
122 		else
123 		static if (is(T U : U[]) && !hasIndirections!U) // void[]
124 			return cast(T) v;
125 		else
126 			return jsonParse!T(cast(string) v);
127 	}
128 
129 	template SqlType(T)
130 	{
131 		static if (is(T : long))
132 			alias SqlType = long;
133 		else
134 		static if (is(T : const(char)[]))
135 			alias SqlType = string;
136 		else
137 		static if (is(T U : U[]) && !hasIndirections!U)
138 			alias SqlType = void[];
139 		else
140 			alias SqlType = string; // JSON-encoded
141 	}
142 
143 	static assert(is(SqlType!int == long));
144 	static assert(is(SqlType!string == string));
145 
146 	template sqlTypeName(T)
147 	{
148 		alias S = SqlType!T;
149 		static if (is(S == long))
150 			enum sqlTypeName = "INTEGER";
151 		else
152 		static if (is(S == string))
153 			enum sqlTypeName = "TEXT";
154 		else
155 		static if (is(S == void[]))
156 			enum sqlTypeName = "BLOB";
157 		else
158 			enum sqlTypeName = "TEXT"; // JSON
159 	}
160 
161 	bool initialized;
162 
163 	SQLite.PreparedStatement sqlGet, sqlSet, sqlDelete, sqlExists, sqlLength;
164 
165 	void checkInitialized()
166 	{
167 		if (!initialized)
168 		{
169 			assert(db, "KeyValueStore database not set");
170 			db.exec("CREATE TABLE IF NOT EXISTS [" ~ tableName ~ "] ([key] " ~ sqlTypeName!K ~ " PRIMARY KEY, [value] " ~ sqlTypeName!V ~ ")");
171 			db.exec("PRAGMA SYNCHRONOUS=OFF");
172 			sqlGet = db.prepare("SELECT [value] FROM [" ~ tableName ~ "] WHERE [key]=?");
173 			sqlSet = db.prepare("INSERT OR REPLACE INTO [" ~ tableName ~ "] VALUES (?, ?)");
174 			sqlDelete = db.prepare("DELETE FROM [" ~ tableName ~ "] WHERE [key]=?");
175 			sqlExists = db.prepare("SELECT COUNT(*) FROM [" ~ tableName ~ "] WHERE [key]=? LIMIT 1");
176 			sqlLength = db.prepare("SELECT COUNT(*) FROM [" ~ tableName ~ "]");
177 			initialized = true;
178 		}
179 	}
180 }
181 
182 struct KeyValueDatabase
183 {
184 	string fileName;
185 
186 	SQLite sqlite;
187 
188 	@property SQLite getSQLite()
189 	{
190 		if (sqlite is null)
191 		{
192 			enforce(fileName, "KeyValueDatabase filename not set");
193 			sqlite = new SQLite(fileName);
194 		}
195 		return sqlite;
196 	}
197 
198 	alias getSQLite this;
199 }
200 
201 unittest
202 {
203 	import std.file;
204 
205 	string fn = tempDir ~ "/ae-sys-persistence-keyvalue-test.s3db";
206 	if (fn.exists) fn.remove();
207 	//scope(exit) if (fn.exists) fn.remove();
208 	auto store = KeyValueStore!(string, string)(fn);
209 
210 	assert(store.length == 0);
211 	assert("key" !in store);
212 	assert(store.get("key", null) is null);
213 
214 	store["key"] = "value";
215 
216 	assert(store.length == 1);
217 	assert("key" in store);
218 	assert(store["key"] == "value");
219 	assert(store.get("key", null) == "value");
220 
221 	store["key"] = "value2";
222 
223 	assert(store.length == 1);
224 	assert("key" in store);
225 	assert(store.get("key", null) == "value2");
226 
227 	store["key2"] = "value3";
228 
229 	assert(store.length == 2);
230 	assert("key" in store);
231 	assert("key2" in store);
232 	assert(store.get("key", null) == "value2");
233 	assert(store.get("key2", null) == "value3");
234 
235 	store.remove("key");
236 
237 	assert(store.length == 1);
238 	assert("key" !in store);
239 	assert("key2" in store);
240 	assert(store.get("key", null) is null);
241 }