1 /** 2 Copyright: Copyright (c) 2018-2019, Joakim Brännström. All rights reserved. 3 License: MIT 4 Author: Joakim Brännström (joakim.brannstrom@gmx.com) 5 6 This module contains an AST representation of a database query. The intention 7 is to encode the structure in the types so errors in the SQL statement are 8 detected at compile time. 9 10 # Grammar 11 12 The grammar is expressed in PEG form using the same terminology as the dub 13 package Pegged. It has not been verified to be correct so may contain errors. 14 15 This is simplified to only contain those parts of the grammar that is needed to 16 pretty print constructed SQL queries. It is not intended to be a grammar that 17 can correctly parse SQL. Thus it contains "blobs of data" that the grammar do 18 not care about. The important part is to keep the structure. Where to insert 19 "blobs". 20 21 A secondary purpose is to give some compile time guarantees of the constructed 22 SQL queries. If it compiles it is reasonably assured to be correct. 23 24 When not necessary for pretty printing as a valid SQL blanks and such are 25 skipped in the grammar definition. 26 27 ```sql 28 SQL <- blank* Query (spacing / eoi) 29 30 Query <- Select 31 32 # --- SELECT START --- 33 Select <- "SELECT" :blank+ ResultColumns :blank+ From? Where? GroupBy? (Window :blank+)? OrderBy? Values? Limit? 34 35 ResultColumns <- ResultColumn ("," ResultColumn)* 36 ResultColumn <- Star / ResultColumnExpr 37 ResultColumnExpr <- Query / Blob 38 39 From <- :blank+ "FROM" :blank+ (TableOrSubQueries / Blob) 40 Where <- :blank+ "WHERE" :blank+ WhereExpr* 41 GroupBy <- :blank+ "GROUP BY" :blank+ Expr ("," Expr)* (:blank+ "HAVING" Expr)? 42 Window <- Blob 43 44 WhereExpr <- Expr (:blank+ WhereOp :blank+ Expr)? 45 WhereOp <- "AND" / "OR" 46 47 OrderBy <- :blank+ "ORDER BY" :blank+ OrderingTerm ("," OrderingTerm) 48 OrderingTerm <- Expr :blank+ OrderingTermSort? 49 OrderingTermSort <- "ASC" / "DESC" / "" 50 51 Limit <- "LIMIT" :blank+ Expr :blank+ (("OFFSET" :blank+ Expr) / ("," Expr))? 52 53 TableOrSubQueries <- TableOrQuery ("," TableOrSubQuery)* 54 TableOrSubQuery <- TableOrSubQuerySelect / ("(" TableOrSubQueries ")") / (TableRef Blob*) / Blob 55 TableOrSubQuerySelect <- "(" Select ")" TableAlias? 56 57 # --- SELECT END --- 58 59 # --- INSERT START --- 60 Insert <- InsertOpt :blank+ "INTO" :blank+ TableRef TableAlias? InsertColumns? InsertValues 61 InsertOpt <- "INSERT" / "REPLACE" / "INSERT OR REPLACE" / "INSERT OR ROLLBACK" / "INSERT OR ABORT" / "INSERT OR FAIL" / "INSERT OR IGNORE" 62 InsertColumns <- :blank+ "(" ColumnName ("," ColumnName)* ")" 63 InsertValues <- :blank+ (Values / Select / "DEFAULT VALUES") 64 ColumnName <- identifier 65 66 # --- INSERT END --- 67 68 # --- DELETE START --- 69 Delete <- "DELETE FROM" :blank+ TableRef Where? 70 # --- DELETE END --- 71 72 # Reference an existing table 73 TableRef <- SchemaName? TableName TableAlias? 74 75 Values <- :blank+ "VALUES" "(" Value ")" ("(" Value ")")* 76 Value <- Expr ("," Expr)* 77 78 TableAlias <- :blank+ "AS" :blank+ identifier 79 80 Expr <- Blob 81 # Not representable in the grammar because it can be anything without a formal 82 # terminator. Its purpose is to be an injection point of user data. 83 Blob <- "" 84 85 SchemaName <- identifier "." 86 TableName <- identifier 87 Star <- "*" 88 ``` 89 90 ## Grammar Encoding 91 92 * `SumType` is used when possible. 93 * `None` is the first member of a `SumType` when the node is optional. 94 * Literals are used as is. 95 * All nodes have a `toString`. 96 */ 97 module miniorm.query_ast; 98 99 import std.array : empty; 100 import std.conv : to; 101 import std.format : formattedWrite, format; 102 import std.meta : AliasSeq; 103 import std.range.primitives : put, isOutputRange; 104 import std.traits : TemplateArgsOf; 105 106 import sumtype; 107 108 @safe: 109 110 /// A SQL statement. 111 struct Sql { 112 Query query; 113 alias query this; 114 115 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 116 query.toString(w); 117 put(w, ";"); 118 } 119 120 string toString()() { 121 import std.array : appender; 122 123 auto app = appender!string(); 124 this.toString(app); 125 return app.data; 126 } 127 } 128 129 /** A SQL query. 130 * 131 * The differents between Sql and Query is that this may be nested in other nodes. 132 */ 133 struct Query { 134 SumType!(Select, Insert, Delete) value; 135 alias value this; 136 137 static foreach (T; TemplateArgsOf!(typeof(value))) { 138 this(T n) { 139 value = typeof(value)(n); 140 } 141 } 142 143 mixin ToStringSumType!value; 144 } 145 146 // ######################################################################### 147 /// # Select START 148 // ######################################################################### 149 150 /// A Select statement. 151 struct Select { 152 ResultColumns columns; 153 154 /// Optional parts of the statement. At least one must in the end be active. 155 SumType!(None, From) from; 156 SumType!(None, Where) where; 157 //SumType!(None, Window) window_; 158 SumType!(None, OrderBy) orderBy; 159 SumType!(None, Limit) limit; 160 161 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 162 put(w, "SELECT "); 163 columns.toString(w); 164 165 put(w, " "); 166 167 // TODO: add an assert that at least one of opts is not None? 168 from.match!((None v) {}, (From v) { v.toString(w); }); 169 where.match!((None v) {}, (Where v) { v.toString(w); }); 170 //window.match!((None v) {}, (Window v) { v.toString(w); }); 171 orderBy.match!((None v) {}, (OrderBy v) { v.toString(w); }); 172 limit.match!((None v) {}, (Limit v) { v.toString(w); }); 173 } 174 } 175 176 struct ResultColumns { 177 ResultColumn required; 178 ResultColumn[] optional; 179 180 this(ResultColumn r, ResultColumn[] o = null) { 181 required = r; 182 optional = o; 183 } 184 185 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 186 required.toString(w); 187 foreach (v; optional) { 188 put(w, ","); 189 v.toString(w); 190 } 191 } 192 } 193 194 struct ResultColumn { 195 SumType!(Star, ResultColumnExpr) value; 196 mixin ToStringSumType!(value); 197 mixin(makeCtor!(typeof(value))("value")); 198 } 199 200 struct ResultColumnExpr { 201 SumType!(Blob, Query*) value; 202 mixin ToStringSumType!value; 203 mixin(makeCtor!(typeof(value))("value")); 204 } 205 206 struct From { 207 SumType!(TableOrSubQueries, Blob) value; 208 alias value this; 209 mixin(makeCtor!(typeof(value))("value")); 210 211 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 212 put(w, "FROM "); 213 value.match!((TableOrSubQueries v) => v.toString(w), (Blob v) { 214 v.toString(w); 215 }); 216 } 217 } 218 219 struct Where { 220 SumType!(None, WhereExpr) value; 221 alias value this; 222 mixin(makeCtor!(typeof(value))("value")); 223 224 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 225 // TODO: should it quote strings? 226 put(w, " WHERE "); 227 value.match!((None v) {}, (WhereExpr v) => v.toString(w)); 228 } 229 } 230 231 struct WhereExpr { 232 static struct Opt { 233 WhereOp op; 234 Expr expr; 235 } 236 237 Expr required; 238 Opt[] optional; 239 240 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 241 required.toString(w); 242 foreach (v; optional) { 243 put(w, " "); 244 put(w, v.op.to!string); 245 put(w, " "); 246 v.expr.toString(w); 247 } 248 } 249 } 250 251 enum WhereOp { 252 AND, 253 OR 254 } 255 256 struct TableOrSubQueries { 257 TableOrQuery required; 258 TableOrQuery[] optional; 259 260 /// 261 this(TableOrQuery r, TableOrQuery[] o = null) @safe pure nothrow @nogc { 262 required = r; 263 optional = o; 264 } 265 266 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 267 required.toString(w); 268 foreach (v; optional) { 269 put(w, ","); 270 v.toString(w); 271 } 272 } 273 } 274 275 struct TableOrQuery { 276 SumType!(TableOrSubQuerySelect*, TableOrSubQueries*, TableRef, Blob) value; 277 alias value this; 278 mixin(makeCtor!(typeof(value))("value")); 279 280 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 281 value.match!((TableOrSubQuerySelect* v) { v.toString(w); }, (TableOrSubQueries* v) { 282 put(w, "("); 283 v.toString(w); 284 put(w, ")"); 285 }, (TableRef v) { v.toString(w); }, (Blob v) { v.toString(w); }); 286 } 287 } 288 289 struct TableOrSubQuerySelect { 290 Select select; 291 TableAlias alias_; 292 293 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 294 put(w, "("); 295 select.toString(w); 296 put(w, ")"); 297 alias_.toString(w); 298 } 299 } 300 301 struct OrderBy { 302 OrderingTerm required; 303 OrderingTerm[] optional; 304 305 this(typeof(required) r, typeof(optional) o = null) @safe pure nothrow @nogc { 306 required = r; 307 optional = o; 308 } 309 310 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 311 put(w, " ORDER BY "); 312 required.toString(w); 313 foreach (v; optional) { 314 put(w, ","); 315 v.toString(w); 316 } 317 } 318 } 319 320 struct OrderingTerm { 321 SumType!(None, Blob) expr; 322 SumType!(None, OrderingTermSort) sortTerm; 323 324 mixin(makeCtor!(typeof(expr))("expr")); 325 326 this(Blob expr, OrderingTermSort sortTerm) @safe pure nothrow @nogc { 327 this.expr = expr; 328 this.sortTerm = sortTerm; 329 } 330 331 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 332 expr.match!((None n) {}, (Blob v) { v.toString(w); }); 333 sortTerm.match!((None n) {}, (OrderingTermSort v) { 334 put(w, " "); 335 put(w, v.to!string); 336 }); 337 } 338 } 339 340 enum OrderingTermSort { 341 ASC, 342 DESC, 343 } 344 345 struct Limit { 346 SumType!(None, Blob) expr; 347 SumType!(None, LimitOffset, Blob) optional; 348 349 mixin(makeCtor!(typeof(expr))("expr")); 350 351 this(Blob expr, LimitOffset l) @safe pure nothrow @nogc { 352 this.expr = expr; 353 this.optional = l; 354 } 355 356 this(Blob expr, Blob l) @safe pure nothrow @nogc { 357 this.expr = expr; 358 this.optional = l; 359 } 360 361 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 362 put(w, " LIMIT "); 363 expr.match!((None n) {}, (Blob v) { v.toString(w); }); 364 optional.match!((None n) {}, (LimitOffset v) { 365 put(w, " OFFSET "); 366 v.toString(w); 367 }, (Blob v) { put(w, ","); v.toString(w); }); 368 } 369 } 370 371 struct LimitOffset { 372 Blob expr; 373 alias expr this; 374 } 375 376 // ######################################################################### 377 /// # Select END 378 // ######################################################################### 379 380 // ######################################################################### 381 /// # Insert START 382 // ######################################################################### 383 384 struct Insert { 385 /// Type of operation to perform. 386 InsertOpt opt; 387 /// Table to operate on. 388 TableRef table; 389 TableAlias alias_; 390 /// 391 InsertColumns columns; 392 /// 393 InsertValues values; 394 395 /// 396 this(InsertOpt opt, TableRef tbl) @safe pure nothrow @nogc { 397 this.opt = opt; 398 this.table = tbl; 399 } 400 401 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 402 final switch (opt) with (InsertOpt) { 403 case Insert: 404 put(w, "INSERT"); 405 break; 406 case Replace: 407 put(w, "REPLACE"); 408 break; 409 case InsertOrReplace: 410 put(w, "INSERT OR REPLACE"); 411 break; 412 case InsertOrRollback: 413 put(w, "INSERT OR ROLLBACK"); 414 break; 415 case InsertOrAbort: 416 put(w, "INSERT OR ABORT"); 417 break; 418 case InsertOrFail: 419 put(w, "INSERT OR FAIL"); 420 break; 421 case InsertOrIgnore: 422 put(w, "INSERT OR IGNORE"); 423 break; 424 } 425 put(w, " INTO "); 426 table.toString(w); 427 alias_.toString(w); 428 columns.toString(w); 429 values.toString(w); 430 } 431 } 432 433 struct InsertColumns { 434 SumType!(None, ColumnNames) value; 435 mixin(makeCtor!(typeof(value))("value")); 436 437 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 438 value.match!((None v) {}, (ColumnNames v) => v.toString(w)); 439 } 440 } 441 442 struct ColumnNames { 443 ColumnName required; 444 ColumnName[] optional; 445 446 this(ColumnName r, ColumnName[] o = null) { 447 required = r; 448 optional = o; 449 } 450 451 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 452 put(w, " ('"); 453 required.toString(w); 454 foreach (v; optional) { 455 put(w, "','"); 456 v.toString(w); 457 } 458 put(w, "')"); 459 } 460 } 461 462 alias ColumnName = Blob; 463 464 struct InsertValues { 465 SumType!(None, Select, Values, InsertDefaultValue) value; 466 mixin(makeCtor!(typeof(value))("value")); 467 468 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 469 value.match!((None v) {}, (Select v) { put(w, " "); v.toString(w); }, (Values v) { 470 v.toString(w); 471 }, (InsertDefaultValue v) { put(w, " "); v.toString(w); }); 472 } 473 } 474 475 struct Values { 476 Value required; 477 Value[] optional; 478 479 this(Value r, Value[] o = null) { 480 required = r; 481 optional = o; 482 } 483 484 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 485 put(w, " VALUES ("); 486 required.toString(w); 487 put(w, ")"); 488 foreach (v; optional) { 489 put(w, ",("); 490 v.toString(w); 491 put(w, ")"); 492 } 493 } 494 } 495 496 struct Value { 497 Expr required; 498 Expr[] optional; 499 500 this(Expr r, Expr[] o = null) { 501 required = r; 502 optional = o; 503 } 504 505 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 506 required.toString(w); 507 foreach (v; optional) { 508 put(w, ","); 509 v.toString(w); 510 } 511 } 512 } 513 514 alias InsertDefaultValue = Constant!"DEFAULT VALUES"; 515 516 /// Based on those that are valid in SQLite. 517 enum InsertOpt { 518 Insert, 519 Replace, 520 InsertOrReplace, 521 InsertOrRollback, 522 InsertOrAbort, 523 InsertOrFail, 524 InsertOrIgnore, 525 } 526 527 // ######################################################################### 528 /// # Insert END 529 // ######################################################################### 530 531 // ######################################################################### 532 /// # Delete START 533 // ######################################################################### 534 535 struct Delete { 536 TableRef table; 537 SumType!(None, Where) where; 538 539 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 540 put(w, "DELETE FROM "); 541 table.toString(w); 542 where.match!((None v) {}, (Where v) { v.toString(w); }); 543 } 544 } 545 546 // ######################################################################### 547 /// # Delete END 548 // ######################################################################### 549 550 struct TableAlias { 551 string value; 552 alias value this; 553 554 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 555 if (value.empty) 556 return; 557 put(w, " AS "); 558 put(w, value); 559 } 560 } 561 562 /// Reference to a table with options to reference another schema and/or create an alias. 563 struct TableRef { 564 SumType!(None, SchemaName) schemaName; 565 string tableName; 566 SumType!(None, TableAlias) tableAlias; 567 568 this(SchemaName schema, string name, TableAlias alias_) { 569 schemaName = schema; 570 tableName = name; 571 tableAlias = alias_; 572 } 573 574 /// A ref to a table that rename it via an "AS" to `alias_`. 575 this(string name, TableAlias alias_) { 576 tableName = name; 577 tableAlias = alias_; 578 } 579 580 /// A simple ref to a table. 581 this(string tableName) { 582 this.tableName = tableName; 583 } 584 585 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 586 schemaName.match!((auto ref v) => v.toString(w)); 587 put(w, tableName); 588 tableAlias.match!((auto ref v) => v.toString(w)); 589 } 590 } 591 592 struct SchemaName { 593 string value; 594 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 595 put(w, value); 596 put(w, "."); 597 } 598 } 599 600 struct Blob { 601 string value; 602 603 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 604 put(w, value); 605 } 606 } 607 608 alias Expr = Blob; 609 alias None = Constant!(string.init); 610 alias Star = Constant!"*"; 611 alias Window = Blob; 612 613 private: 614 615 /// Create a match that calls `.toString(w)` on all matches of the SumType. 616 mixin template ToStringSumType(alias member) { 617 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 618 static string autoMatch(alias member)() { 619 string rval = q{%s.match!(}.format(member.stringof); 620 static foreach (T; TemplateArgsOf!(typeof(member))) { 621 rval ~= q{(%s v) => v.toString(w),}.format(T.stringof); 622 } 623 rval ~= ");"; 624 return rval; 625 } 626 627 mixin(autoMatch!member); 628 } 629 } 630 631 string makeBuild(ArgT, string member, string funcName)() { 632 string rval = q{auto %s(%s v)}.format(funcName, ArgT.stringof); 633 rval ~= "{"; 634 rval ~= member ~ " = v;"; 635 rval ~= q{return this;}; 636 rval ~= "}"; 637 return rval; 638 } 639 640 /// ctors for all template arguments of member. 641 string makeCtor(SumT)(string var) { 642 string rval; 643 static foreach (T; TemplateArgsOf!SumT) { 644 rval ~= q{this(%1$s n) @safe pure nothrow { 645 this.%2$s = n; 646 }}.format(T.stringof, var); 647 rval ~= "\n"; 648 } 649 return rval; 650 } 651 652 /// Returns: a string that can be mixed in to create a setter for the member 653 mixin template makeBuilder(members...) { 654 static string buildMember(alias member)() { 655 enum memberStr = member.stringof; 656 static assert(memberStr[$ - 1] == '_', "member must end with '_': " ~ memberStr); 657 658 enum Type = typeof(member).stringof; 659 string rval = q{auto %s(%s v)}.format(member.stringof[0 .. $ - 1], Type); 660 rval ~= "{"; 661 rval ~= memberStr ~ " = v;"; 662 rval ~= q{return this;}; 663 rval ~= "}"; 664 return rval; 665 } 666 667 static foreach (member; members) { 668 mixin(buildMember!member); 669 } 670 } 671 672 /// A node representing a constant value. 673 struct Constant(string s) { 674 string value = s; 675 alias value this; 676 677 void toString(Writer)(ref Writer w) if (isOutputRange!(Writer, char)) { 678 put(w, value); 679 } 680 } 681 682 version (unittest) { 683 import unit_threaded.assertions : shouldEqual; 684 } 685 686 @("shall convert a query at compile time to SQL") 687 unittest { 688 enum q = Select().Query.Sql.toString; 689 q.shouldEqual("SELECT * ;"); 690 } 691 692 @("shall convert a Select using From to SQL") 693 unittest { 694 // arrange 695 Select qblob, qtblRef, q; 696 // act 697 qblob.from = Blob("foo").From; 698 qtblRef.from = TableOrSubQueries(TableOrQuery(TableRef("foo"))).From; 699 // assert 700 immutable expected = "SELECT * FROM foo;"; 701 foreach (s; [qblob, qtblRef]) 702 s.Query.Sql.toString.shouldEqual(expected); 703 } 704 705 @("shall convert a Select using a subquery in FROM to SQL") 706 unittest { 707 // arrange 708 Select qblob, qAlias, qRef, qsubBlob; 709 // act 710 qsubBlob.from = Blob("foo I dance").From; 711 qblob.from = TableOrSubQueries(TableOrQuery(new TableOrSubQuerySelect(qsubBlob))).From; 712 qAlias.from = TableOrSubQueries(TableOrQuery(new TableOrSubQuerySelect(qsubBlob, 713 TableAlias("bar")))).From; 714 qRef.from = TableOrSubQueries(TableOrQuery(new TableOrSubQueries(TableRef("foo") 715 .TableOrQuery, [TableRef("smurf").TableOrQuery]))).From; 716 // assert 717 // a subquery as a blob that should be represented as-is. 718 qblob.Query.Sql.toString.shouldEqual("SELECT * FROM (SELECT * FROM foo I dance);"); 719 // a subquery as a named select. 720 qAlias.Query.Sql.toString.shouldEqual("SELECT * FROM (SELECT * FROM foo I dance) AS bar;"); 721 // multiple table refs. 722 qRef.Query.Sql.toString.shouldEqual("SELECT * FROM (foo,smurf);"); 723 } 724 725 @("shall convert a Select using an OrderBy to SQL") 726 unittest { 727 // arrange 728 Select q; 729 q.from = Blob("foo").From; 730 // act 731 q.orderBy = OrderBy(OrderingTerm(Blob("bar"))); 732 // assert 733 q.Query.Sql.toString.shouldEqual("SELECT * FROM foo ORDER BY bar;"); 734 } 735 736 @("shall convert a Select using Where to SQL") 737 unittest { 738 // arrange 739 Select q; 740 // act 741 q.from = Blob("foo").From; 742 q.where = WhereExpr(Expr("foo = bar"), [ 743 WhereExpr.Opt(WhereOp.OR, Expr("batman NOT NULL")) 744 ]).Where; 745 // assert 746 q.Query.Sql.toString.shouldEqual("SELECT * FROM foo WHERE foo = bar OR batman NOT NULL;"); 747 } 748 749 @("shall convert an Insert using default values to SQL") 750 unittest { 751 // act 752 auto q = Insert(InsertOpt.Insert, TableRef("foo")); 753 q.values = InsertValues(InsertDefaultValue.init); 754 // assert 755 q.Query.Sql.toString.shouldEqual("INSERT INTO foo DEFAULT VALUES;"); 756 } 757 758 @("shall convert an Insert using specific values to SQL") 759 unittest { 760 // act 761 auto q = Insert(InsertOpt.Insert, TableRef("foo")); 762 q.values = InsertValues(Values(Value(Expr("1"), [Expr("2")]), [ 763 Value(Expr("4"), [Expr("5")]) 764 ])); 765 // assert 766 q.Query.Sql.toString.shouldEqual("INSERT INTO foo VALUES (1,2),(4,5);"); 767 } 768 769 @("shall convert an Insert using select stmt to SQL") 770 unittest { 771 // act 772 Select s; 773 s.from = Blob("bar").From; 774 auto q = Insert(InsertOpt.Insert, TableRef("foo")); 775 q.values = InsertValues(s); 776 // assert 777 q.Query.Sql.toString.shouldEqual("INSERT INTO foo SELECT * FROM bar;"); 778 } 779 780 @("shall convert a Select with a limit to SQL") 781 unittest { 782 // arrange 783 Select q; 784 q.from = Blob("foo").From; 785 // act 786 q.limit = Limit(Blob("10"), LimitOffset(Blob("42"))); 787 // assert 788 q.Query.Sql.toString.shouldEqual("SELECT * FROM foo LIMIT 10 OFFSET 42;"); 789 }