1 /**
2 Copyright: Copyright (c) 2017, Oleg Butko. All rights reserved.
3 Copyright: Copyright (c) 2018-2019, Joakim Brännström. All rights reserved.
4 License: MIT
5 Author: Oleg Butko (deviator)
6 Author: Joakim Brännström (joakim.brannstrom@gmx.com)
7 */
8 module miniorm.schema;
9 
10 version (unittest) {
11     import std.algorithm : map;
12     import unit_threaded.assertions;
13 }
14 
15 @safe:
16 
17 /// UDA controlling the name of the table.
18 struct TableName {
19     string value;
20 }
21 
22 /// UDA controlling constraints of a table.
23 struct TableConstraint {
24     string value;
25 }
26 
27 /// UDA setting a field other than id to being the primary key.
28 struct TablePrimaryKey {
29     string value;
30 }
31 
32 /// UDA for foreign keys on a table.
33 struct TableForeignKey {
34     string foreignKey;
35     KeyRef r;
36     KeyParam p;
37 }
38 
39 struct KeyRef {
40     string value;
41 }
42 
43 struct KeyParam {
44     string value;
45 }
46 
47 /// UDA controlling extra attributes for a field.
48 struct ColumnParam {
49     string value;
50 }
51 
52 /// UDA to control the column name that a field end up as.
53 struct ColumnName {
54     string value;
55 }
56 
57 /** Create SQL for creating tables if not exists
58  *
59  * Params:
60  * Types = types of structs which will be a tables
61  *         name of struct -> name of table
62  *         name of field -> name of column
63  * prefix = prefix to use for the tables that are created.
64  *
65  * To change the name of the table:
66  * ---
67  * @TableName("my_name")
68  * struct Foo {}
69  * ---
70  */
71 auto buildSchema(Types...)(string prefix = null) {
72     import std.algorithm : joiner, map;
73     import std.array : appender, array;
74     import std.range : only;
75 
76     auto ret = appender!string;
77     foreach (T; Types) {
78         static if (is(T == struct)) {
79             ret.put("CREATE TABLE IF NOT EXISTS ");
80             ret.put(prefix);
81             ret.put(tableName!T);
82             ret.put(" (\n");
83             ret.put(only(fieldToCol!("", T)().map!"a.toColumn".array,
84                     tableConstraints!T(), tableForeinKeys!T()).joiner.joiner(",\n"));
85             ret.put(");\n");
86         } else
87             static assert(0, "not supported non-struct type");
88     }
89     return ret.data;
90 }
91 
92 unittest {
93     static struct Foo {
94         ulong id;
95         float value;
96         ulong ts;
97     }
98 
99     static struct Bar {
100         ulong id;
101         string text;
102         ulong ts;
103     }
104 
105     buildSchema!(Foo, Bar).shouldEqual(`CREATE TABLE IF NOT EXISTS Foo (
106 'id' INTEGER PRIMARY KEY,
107 'value' REAL NOT NULL,
108 'ts' INTEGER NOT NULL);
109 CREATE TABLE IF NOT EXISTS Bar (
110 'id' INTEGER PRIMARY KEY,
111 'text' TEXT NOT NULL,
112 'ts' INTEGER NOT NULL);
113 `);
114 }
115 
116 @("shall create a schema with a table name derived from the UDA")
117 unittest {
118     @TableName("my_table")
119     static struct Foo {
120         ulong id;
121     }
122 
123     assert(buildSchema!(Foo) == `CREATE TABLE IF NOT EXISTS my_table (
124 'id' INTEGER PRIMARY KEY);
125 `);
126 }
127 
128 @("shall create a schema with an integer column that may be NULL")
129 unittest {
130     static struct Foo {
131         ulong id;
132         @ColumnParam("")
133         ulong int_;
134     }
135 
136     buildSchema!(Foo).shouldEqual(`CREATE TABLE IF NOT EXISTS Foo (
137 'id' INTEGER PRIMARY KEY,
138 'int_' INTEGER);
139 `);
140 }
141 
142 @("shall create a schema with constraints from UDAs")
143 unittest {
144     @TableConstraint("u UNIQUE p")
145     static struct Foo {
146         ulong id;
147         ulong p;
148     }
149 
150     assert(buildSchema!(Foo) == `CREATE TABLE IF NOT EXISTS Foo (
151 'id' INTEGER PRIMARY KEY,
152 'p' INTEGER NOT NULL,
153 CONSTRAINT u UNIQUE p);
154 `, buildSchema!(Foo));
155 }
156 
157 @("shall create a schema with a foregin key from UDAs")
158 unittest {
159     @TableForeignKey("p", KeyRef("bar(id)"), KeyParam("ON DELETE CASCADE"))
160     static struct Foo {
161         ulong id;
162         ulong p;
163     }
164 
165     assert(buildSchema!(Foo) == `CREATE TABLE IF NOT EXISTS Foo (
166 'id' INTEGER PRIMARY KEY,
167 'p' INTEGER NOT NULL,
168 FOREIGN KEY(p) REFERENCES bar(id) ON DELETE CASCADE);
169 `, buildSchema!(Foo));
170 }
171 
172 @("shall create a schema with a name from UDA")
173 unittest {
174     static struct Foo {
175         ulong id;
176         @ColumnName("version")
177         ulong version_;
178     }
179 
180     assert(buildSchema!(Foo) == `CREATE TABLE IF NOT EXISTS Foo (
181 'id' INTEGER PRIMARY KEY,
182 'version' INTEGER NOT NULL);
183 `, buildSchema!(Foo));
184 }
185 
186 @("shall create a schema with a table name derived from the UDA with specified prefix")
187 unittest {
188     @TableName("my_table")
189     static struct Foo {
190         ulong id;
191     }
192 
193     buildSchema!Foo("new_").shouldEqual(`CREATE TABLE IF NOT EXISTS new_my_table (
194 'id' INTEGER PRIMARY KEY);
195 `);
196 }
197 
198 @("shall create a schema with a column of type DATETIME")
199 unittest {
200     import std.datetime : SysTime;
201 
202     static struct Foo {
203         ulong id;
204         SysTime timestamp;
205     }
206 
207     buildSchema!Foo.shouldEqual(`CREATE TABLE IF NOT EXISTS Foo (
208 'id' INTEGER PRIMARY KEY,
209 'timestamp' DATETIME NOT NULL);
210 `);
211 }
212 
213 @("shall create a schema with a column where the second column is a string and primary key")
214 unittest {
215     import std.datetime : SysTime;
216 
217     @TablePrimaryKey("key")
218     static struct Foo {
219         ulong id;
220         string key;
221     }
222 
223     buildSchema!Foo.shouldEqual(`CREATE TABLE IF NOT EXISTS Foo (
224 'id' INTEGER NOT NULL,
225 'key' TEXT PRIMARY KEY);
226 `);
227 }
228 
229 import std.format : format, formattedWrite;
230 import std.traits;
231 import std.meta : Filter;
232 
233 package:
234 
235 enum SEPARATOR = ".";
236 
237 string tableName(T)() {
238     enum nameAttrs = getUDAs!(T, TableName);
239     static assert(nameAttrs.length == 0 || nameAttrs.length == 1,
240             "Found multiple TableName UDAs on " ~ T.stringof);
241     enum hasName = nameAttrs.length;
242     static if (hasName) {
243         return nameAttrs[0].value;
244     } else
245         return T.stringof;
246 }
247 
248 string[] tableConstraints(T)() {
249     enum constraintAttrs = getUDAs!(T, TableConstraint);
250     enum hasConstraints = constraintAttrs.length;
251 
252     string[] rval;
253     static if (hasConstraints) {
254         static foreach (const c; constraintAttrs)
255             rval ~= "CONSTRAINT " ~ c.value;
256     }
257     return rval;
258 }
259 
260 string tablePrimaryKey(T)() {
261     enum keyAttr = getUDAs!(T, TablePrimaryKey);
262     static if (keyAttr.length != 0) {
263         return keyAttr[0].value;
264     } else
265         return "id";
266 }
267 
268 string[] tableForeinKeys(T)() {
269     enum foreignKeyAttrs = getUDAs!(T, TableForeignKey);
270     enum hasForeignKeys = foreignKeyAttrs.length;
271 
272     string[] rval;
273     static if (hasForeignKeys) {
274         static foreach (a; foreignKeyAttrs)
275             rval ~= "FOREIGN KEY(" ~ a.foreignKey ~ ") REFERENCES " ~ a.r.value ~ (
276                     (a.p.value.length == 0) ? null : " " ~ a.p.value);
277     }
278 
279     return rval;
280 }
281 
282 string[] fieldNames(string name, T)(string prefix = "") {
283     static if (is(T == struct)) {
284         T t;
285         string[] ret;
286         foreach (i, f; t.tupleof) {
287             enum fname = __traits(identifier, t.tupleof[i]);
288             alias F = typeof(f);
289             auto np = prefix ~ (name.length ? name ~ SEPARATOR : "");
290             ret ~= fieldNames!(fname, F)(np);
291         }
292         return ret;
293     } else
294         return ["'" ~ prefix ~ name ~ "'"];
295 }
296 
297 @("shall derive the field names from the inspected struct")
298 unittest {
299     struct Foo {
300         ulong id;
301         float xx;
302         string yy;
303     }
304 
305     struct Bar {
306         ulong id;
307         float abc;
308         Foo foo;
309         string baz;
310     }
311 
312     import std.algorithm;
313     import std.utf;
314 
315     assert(fieldNames!("", Bar) == [
316             "'id'", "'abc'", "'foo.id'", "'foo.xx'", "'foo.yy'", "'baz'"
317             ]);
318     fieldToCol!("", Bar).map!"a.quoteIdentifier".shouldEqual([
319             "'id'", "'abc'", "'foo.id'", "'foo.xx'", "'foo.yy'", "'baz'"
320             ]);
321 }
322 
323 struct FieldColumn {
324     /// Identifier in the struct.
325     string identifier;
326     /// Name of the column in the table.
327     string columnName;
328     /// The type is user defined
329     string columnType;
330     /// Parameters for the column when creating the table.
331     string columnParam;
332     /// If the field is a primary key.
333     bool isPrimaryKey;
334 
335     string quoteIdentifier() @safe pure nothrow const {
336         return "'" ~ identifier ~ "'";
337     }
338 
339     string quoteColumnName() @safe pure nothrow const {
340         return "'" ~ columnName ~ "'";
341     }
342 
343     string toColumn() @safe pure nothrow const {
344         return quoteColumnName ~ " " ~ columnType ~ columnParam;
345     }
346 }
347 
348 FieldColumn[] fieldToCol(string name, T)(string prefix = "") {
349     return fieldToColRecurse!(name, T, 0)(prefix);
350 }
351 
352 private:
353 
354 FieldColumn[] fieldToColRecurse(string name, T, ulong depth)(string prefix) {
355     import std.datetime : SysTime;
356     import std.meta : AliasSeq;
357 
358     static if (!is(T == struct))
359         static assert(
360                 "Building a schema from a type is only supported for struct's. This type is not supported: "
361                 ~ T.stringof);
362 
363     static if (tablePrimaryKey!T.length != 0)
364         enum primaryKey = tablePrimaryKey!T;
365     else
366         enum primaryKey = "id";
367 
368     T t;
369     FieldColumn[] ret;
370     foreach (i, f; t.tupleof) {
371         enum fname = __traits(identifier, t.tupleof[i]);
372         alias F = typeof(f);
373         auto np = prefix ~ (name.length ? name ~ SEPARATOR : "");
374 
375         enum udas = AliasSeq!(getUDAs!(t.tupleof[i], ColumnParam),
376                     getUDAs!(t.tupleof[i], ColumnName));
377 
378         static if (is(F == SysTime))
379             ret ~= fieldToColInternal!(fname, primaryKey, F, depth, udas)(np);
380         else static if (is(F == struct))
381             ret ~= fieldToColRecurse!(fname, F, depth + 1)(np);
382         else
383             ret ~= fieldToColInternal!(fname, primaryKey, F, depth, udas)(np);
384     }
385     return ret;
386 }
387 
388 /**
389  * Params:
390  *  depth = A primary key can only be at the outer most struct. Any other "id" fields are normal integers.
391  */
392 FieldColumn[] fieldToColInternal(string name, string primaryKey, T, ulong depth, FieldUDAs...)(
393         string prefix) {
394     import std.datetime : SysTime;
395     import std.traits : OriginalType;
396 
397     enum bool isFieldParam(alias T) = is(typeof(T) == ColumnParam);
398     enum bool isFieldName(alias T) = is(typeof(T) == ColumnName);
399 
400     string type, param;
401 
402     enum paramAttrs = Filter!(isFieldParam, FieldUDAs);
403     static assert(paramAttrs.length == 0 || paramAttrs.length == 1,
404             "Found multiple ColumnParam UDAs on " ~ T.stringof);
405     enum hasParam = paramAttrs.length;
406     static if (hasParam) {
407         static if (paramAttrs[0].value.length == 0)
408             param = "";
409         else
410             param = " " ~ paramAttrs[0].value;
411     } else
412         param = " NOT NULL";
413 
414     static if (is(T == enum))
415         alias originalT = OriginalType!T;
416     else
417         alias originalT = T;
418 
419     static if (isFloatingPoint!originalT)
420         type = "REAL";
421     else static if (isNumeric!originalT || is(originalT == bool)) {
422         type = "INTEGER";
423     } else static if (isSomeString!originalT)
424         type = "TEXT";
425     else static if (isArray!originalT)
426         type = "BLOB";
427     else static if (is(originalT == SysTime)) {
428         type = "DATETIME";
429     } else
430         static assert(0, "unsupported type: " ~ T.stringof);
431 
432     enum nameAttr = Filter!(isFieldName, FieldUDAs);
433     static assert(nameAttr.length == 0 || nameAttr.length == 1,
434             "Found multiple ColumnName UDAs on " ~ T.stringof);
435     static if (nameAttr.length)
436         enum columnName = nameAttr[0].value;
437     else
438         enum columnName = name;
439 
440     static if (columnName == primaryKey && depth == 0) {
441         return [
442             FieldColumn(prefix ~ name, prefix ~ columnName, type, " PRIMARY KEY", true)
443         ];
444     } else {
445         return [FieldColumn(prefix ~ name, prefix ~ columnName, type, param)];
446     }
447 }
448 
449 unittest {
450     struct Baz {
451         string a, b;
452     }
453 
454     struct Foo {
455         float xx;
456         string yy;
457         Baz baz;
458         int zz;
459     }
460 
461     struct Bar {
462         ulong id;
463         float abc;
464         Foo foo;
465         string baz;
466         ubyte[] data;
467     }
468 
469     enum shouldWorkAtCompileTime = fieldToCol!("", Bar);
470 
471     fieldToCol!("", Bar)().map!"a.toColumn".shouldEqual([
472             "'id' INTEGER PRIMARY KEY", "'abc' REAL NOT NULL",
473             "'foo.xx' REAL NOT NULL", "'foo.yy' TEXT NOT NULL",
474             "'foo.baz.a' TEXT NOT NULL", "'foo.baz.b' TEXT NOT NULL",
475             "'foo.zz' INTEGER NOT NULL", "'baz' TEXT NOT NULL",
476             "'data' BLOB NOT NULL"
477             ]);
478 }