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