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