1 /++ 2 Helper functions for generating database stuff. 3 4 Note: this is heavily biased toward Postgres 5 +/ 6 module arsd.database_generation; 7 8 /* 9 10 FIXME: support partial indexes and maybe "using" 11 FIXME: support views 12 13 Let's put indexes in there too and make index functions be the preferred way of doing a query 14 by making them convenient af. 15 */ 16 17 private enum UDA; 18 19 @UDA struct PrimaryKey { 20 string sql; 21 } 22 23 @UDA struct Default { 24 string sql; 25 } 26 27 @UDA struct Unique { } 28 29 @UDA struct ForeignKey(alias toWhat, string behavior) { 30 alias ReferencedTable = __traits(parent, toWhat); 31 } 32 33 enum CASCADE = "ON UPDATE CASCADE ON DELETE CASCADE"; 34 enum NULLIFY = "ON UPDATE CASCADE ON DELETE SET NULL"; 35 enum RESTRICT = "ON UPDATE CASCADE ON DELETE RESTRICT"; 36 37 @UDA struct DBName { string name; } 38 39 struct Nullable(T) { 40 bool isNull = true; 41 T value; 42 43 void opAssign(typeof(null)) { 44 isNull = true; 45 } 46 47 void opAssign(T v) { 48 isNull = false; 49 value = v; 50 } 51 52 T toArsdJsvar() { return value; } 53 54 string toString() { 55 import std.conv; 56 57 if (isNull) { 58 return "Nullable.null"; 59 } 60 else { 61 return to!string(this.value); 62 } 63 } 64 } 65 66 struct Timestamp { 67 string value; 68 string toArsdJsvar() { return value; } 69 70 // FIXME: timezone 71 static Timestamp fromStrings(string date, string time) { 72 if(time.length < 6) 73 time ~= ":00"; 74 import std.datetime; 75 return Timestamp(SysTime.fromISOExtString(date ~ "T" ~ time).toISOExtString()); 76 } 77 } 78 79 SysTime parseDbTimestamp(Timestamp s) { 80 return parseDbTimestamp(s.value); 81 } 82 83 SysTime parseDbTimestamp(string s) { 84 if(s.length == 0) return SysTime.init; 85 auto date = s[0 .. 10]; 86 auto time = s[11 .. 20]; 87 auto tz = s[20 .. $]; 88 return SysTime.fromISOExtString(date ~ "T" ~ time ~ tz); 89 } 90 91 struct Constraint(string sql) {} 92 93 struct Index(Fields...) {} 94 struct UniqueIndex(Fields...) {} 95 96 struct Serial { 97 int value; 98 int toArsdJsvar() { return value; } 99 int getValue() { return value; } 100 alias getValue this; 101 } 102 103 104 string generateCreateTableFor(alias O)() { 105 enum tableName = tableNameFor!O(); 106 string sql = "CREATE TABLE " ~ tableName ~ " ("; 107 string postSql; 108 bool outputtedPostSql = false; 109 110 string afterTableSql; 111 112 void addAfterTableSql(string s) { 113 afterTableSql ~= s; 114 afterTableSql ~= "\n"; 115 } 116 117 void addPostSql(string s) { 118 if(outputtedPostSql) { 119 postSql ~= ","; 120 } 121 postSql ~= "\n"; 122 postSql ~= "\t" ~ s; 123 outputtedPostSql = true; 124 } 125 126 bool outputted = false; 127 static foreach(memberName; __traits(allMembers, O)) {{ 128 alias member = __traits(getMember, O, memberName); 129 static if(is(typeof(member) == Constraint!constraintSql, string constraintSql)) { 130 version(dbgenerate_sqlite) {} else { // FIXME: make it work here too, it is the specifics of the constraint strings 131 if(outputted) { 132 sql ~= ","; 133 } 134 sql ~= "\n"; 135 sql ~= "\tCONSTRAINT " ~ memberName; 136 sql ~= " "; 137 sql ~= constraintSql; 138 outputted = true; 139 } 140 } else static if(is(typeof(member) == Index!Fields, Fields...)) { 141 string fields = ""; 142 static foreach(field; Fields) { 143 if(fields.length) 144 fields ~= ", "; 145 fields ~= __traits(identifier, field); 146 } 147 addAfterTableSql("CREATE INDEX " ~ tableName ~ "_" ~ memberName ~ " ON " ~ tableName ~ "("~fields~")"); 148 } else static if(is(typeof(member) == UniqueIndex!Fields, Fields...)) { 149 string fields = ""; 150 static foreach(field; Fields) { 151 if(fields.length) 152 fields ~= ", "; 153 fields ~= __traits(identifier, field); 154 } 155 addAfterTableSql("CREATE UNIQUE INDEX " ~ tableName ~ "_" ~ memberName ~ " ON " ~ tableName ~ "("~fields~")"); 156 } else static if(is(typeof(member) T)) { 157 if(outputted) { 158 sql ~= ","; 159 } 160 sql ~= "\n"; 161 sql ~= "\t" ~ memberName; 162 163 static if(is(T == Nullable!P, P)) { 164 static if(is(P == int)) 165 sql ~= " INTEGER NULL"; 166 else static if(is(P == string)) 167 sql ~= " TEXT NULL"; 168 else static if(is(P == double)) 169 sql ~= " FLOAT NULL"; 170 else static if(is(P == Timestamp)) 171 sql ~= " TIMESTAMPTZ NULL"; 172 else static assert(0, P.stringof); 173 } else static if(is(T == int)) 174 sql ~= " INTEGER NOT NULL"; 175 else static if(is(T == Serial)) { 176 version(dbgenerate_sqlite) 177 sql ~= " INTEGER PRIMARY KEY AUTOINCREMENT"; 178 else 179 sql ~= " SERIAL"; // FIXME postgresism 180 } else static if(is(T == string)) 181 sql ~= " TEXT NOT NULL"; 182 else static if(is(T == double)) 183 sql ~= " FLOAT NOT NULL"; 184 else static if(is(T == bool)) 185 sql ~= " BOOLEAN NOT NULL"; 186 else static if(is(T == Timestamp)) { 187 version(dbgenerate_sqlite) 188 sql ~= " TEXT NOT NULL"; 189 else 190 sql ~= " TIMESTAMPTZ NOT NULL"; // FIXME: postgresism 191 } else static if(is(T == enum)) 192 sql ~= " INTEGER NOT NULL"; // potentially crap but meh 193 194 static foreach(attr; __traits(getAttributes, member)) { 195 static if(is(typeof(attr) == Default)) { 196 // FIXME: postgresism there, try current_timestamp in sqlite 197 version(dbgenerate_sqlite) { 198 import std.string; 199 sql ~= " DEFAULT " ~ std..string.replace(attr.sql, "now()", "current_timestamp"); 200 } else 201 sql ~= " DEFAULT " ~ attr.sql; 202 } else static if(is(attr == Unique)) { 203 sql ~= " UNIQUE"; 204 } else static if(is(attr == PrimaryKey)) { 205 version(dbgenerate_sqlite) { 206 static if(is(T == Serial)) {} // skip, it is done above 207 else 208 addPostSql("PRIMARY KEY(" ~ memberName ~ ")"); 209 } else 210 addPostSql("PRIMARY KEY(" ~ memberName ~ ")"); 211 } else static if(is(attr == ForeignKey!(to, sqlPolicy), alias to, string sqlPolicy)) { 212 string refTable = tableNameFor!(__traits(parent, to))(); 213 string refField = to.stringof; 214 addPostSql("FOREIGN KEY(" ~ memberName ~ ") REFERENCES "~refTable~"("~refField~(sqlPolicy.length ? ") " : ")") ~ sqlPolicy); 215 } 216 } 217 218 outputted = true; 219 } 220 }} 221 222 if(postSql.length && outputted) 223 sql ~= ",\n"; 224 225 sql ~= postSql; 226 sql ~= "\n);\n"; 227 sql ~= afterTableSql; 228 229 return sql; 230 } 231 232 string tableNameFor(T)(string def = toTableName(T.stringof)) { 233 foreach(attr; __traits(getAttributes, T)) 234 static if(is(typeof(attr) == DBName)) 235 def = attr.name; 236 return def; 237 } 238 239 string toTableName(string t) { 240 return plural(50, beautify(t, '_', true)); 241 } 242 243 // copy/pasted from english.d 244 private string plural(int count, string word, string pluralWord = null) { 245 if(count == 1 || word.length == 0) 246 return word; // it isn't actually plural 247 248 if(pluralWord !is null) 249 return pluralWord; 250 251 switch(word[$ - 1]) { 252 case 's': 253 return word ~ "es"; 254 case 'f': 255 return word[0 .. $-1] ~ "ves"; 256 case 'y': 257 return word[0 .. $-1] ~ "ies"; 258 case 'a', 'e', 'i', 'o', 'u': 259 default: 260 return word ~ "s"; 261 } 262 } 263 264 // copy/pasted from cgi 265 private string beautify(string name, char space = ' ', bool allLowerCase = false) { 266 if(name == "id") 267 return allLowerCase ? name : "ID"; 268 269 char[160] buffer; 270 int bufferIndex = 0; 271 bool shouldCap = true; 272 bool shouldSpace; 273 bool lastWasCap; 274 foreach(idx, char ch; name) { 275 if(bufferIndex == buffer.length) return name; // out of space, just give up, not that important 276 277 if((ch >= 'A' && ch <= 'Z') || ch == '_') { 278 if(lastWasCap) { 279 // two caps in a row, don't change. Prolly acronym. 280 } else { 281 if(idx) 282 shouldSpace = true; // new word, add space 283 } 284 285 lastWasCap = true; 286 } else { 287 lastWasCap = false; 288 } 289 290 if(shouldSpace) { 291 buffer[bufferIndex++] = space; 292 if(bufferIndex == buffer.length) return name; // out of space, just give up, not that important 293 shouldSpace = false; 294 } 295 if(shouldCap) { 296 if(ch >= 'a' && ch <= 'z') 297 ch -= 32; 298 shouldCap = false; 299 } 300 if(allLowerCase && ch >= 'A' && ch <= 'Z') 301 ch += 32; 302 buffer[bufferIndex++] = ch; 303 } 304 return buffer[0 .. bufferIndex].idup; 305 } 306 307 import arsd.database; 308 /++ 309 310 +/ 311 void save(O)(ref O t, Database db) { 312 t.insert(db); 313 } 314 315 /++ 316 317 +/ 318 void insert(O)(ref O t, Database db) { 319 auto builder = new InsertBuilder; 320 builder.setTable(tableNameFor!O()); 321 322 static foreach(memberName; __traits(allMembers, O)) {{ 323 alias member = __traits(getMember, O, memberName); 324 static if(is(typeof(member) T)) { 325 326 static if(is(T == Nullable!P, P)) { 327 auto v = __traits(getMember, t, memberName); 328 if(v.isNull) 329 builder.addFieldWithSql(memberName, "NULL"); 330 else 331 builder.addVariable(memberName, v.value); 332 } else static if(is(T == int)) 333 builder.addVariable(memberName, __traits(getMember, t, memberName)); 334 else static if(is(T == Serial)) { 335 auto v = __traits(getMember, t, memberName).value; 336 if(v) { 337 builder.addVariable(memberName, v); 338 } else { 339 // skip and let it auto-fill 340 } 341 } else static if(is(T == string)) { 342 builder.addVariable(memberName, __traits(getMember, t, memberName)); 343 } else static if(is(T == double)) 344 builder.addVariable(memberName, __traits(getMember, t, memberName)); 345 else static if(is(T == bool)) 346 builder.addVariable(memberName, __traits(getMember, t, memberName)); 347 else static if(is(T == Timestamp)) { 348 auto v = __traits(getMember, t, memberName).value; 349 if(v.length) 350 builder.addVariable(memberName, v); 351 } else static if(is(T == enum)) 352 builder.addVariable(memberName, cast(int) __traits(getMember, t, memberName)); 353 } 354 }} 355 356 import std.conv; 357 version(dbgenerate_sqlite) { 358 builder.execute(db); 359 foreach(row; db.query("SELECT max(id) FROM " ~ tableNameFor!O())) 360 t.id.value = to!int(row[0]); 361 } else { 362 static if (__traits(hasMember, O, "id")) 363 { 364 foreach(row; builder.execute(db, "RETURNING id")) // FIXME: postgres-ism 365 t.id.value = to!int(row[0]); 366 } 367 else 368 { 369 builder.execute(db); 370 } 371 } 372 } 373 374 // Check that insert doesn't require an `id` 375 unittest 376 { 377 static struct NoPK 378 { 379 int a; 380 } 381 382 alias test = insert!NoPK; 383 } 384 /// 385 class RecordNotFoundException : Exception { 386 this() { super("RecordNotFoundException"); } 387 } 388 389 /++ 390 Returns a given struct populated from the database. Assumes types known to this module. 391 392 MyItem item = db.find!(MyItem.id)(3); 393 394 If you just give a type, it assumes the relevant index is "id". 395 396 +/ 397 auto find(alias T)(Database db, int id) { 398 399 // FIXME: if T is an index, search by it. 400 // if it is unique, return an individual item. 401 // if not, return the array 402 403 foreach(record; db.query("SELECT * FROM " ~ tableNameFor!T() ~ " WHERE id = ?", id)) { 404 T t; 405 populateFromDbRow(t, record); 406 407 return t; 408 // if there is ever a second record, that's a wtf, but meh. 409 } 410 throw new RecordNotFoundException(); 411 } 412 413 private void populateFromDbRow(T)(ref T t, Row record) { 414 foreach(field, value; record) { 415 sw: switch(field) { 416 static foreach(const idx, alias mem; T.tupleof) { 417 case __traits(identifier, mem): 418 populateFromDbVal(t.tupleof[idx], value); 419 break sw; 420 } 421 default: 422 // intentionally blank 423 } 424 } 425 } 426 427 private void populateFromDbVal(V)(ref V val, string value) { 428 import std.conv; 429 static if(is(V == Constraint!constraintSql, string constraintSql)) { 430 431 } else static if(is(V == Nullable!P, P)) { 432 // FIXME 433 if(value.length && value != "null") { 434 val.isNull = false; 435 val.value = to!P(value); 436 } 437 } else static if(is(V == bool)) { 438 val = value == "t" || value == "1" || value == "true"; 439 } else static if(is(V == int) || is(V == string) || is(V == double)) { 440 val = to!V(value); 441 } else static if(is(V == enum)) { 442 val = cast(V) to!int(value); 443 } else static if(is(V == Timestamp)) { 444 val.value = value; 445 } else static if(is(V == Serial)) { 446 val.value = to!int(value); 447 } 448 } 449 450 unittest 451 { 452 static struct SomeStruct 453 { 454 int a; 455 void foo() {} 456 int b; 457 } 458 459 auto rs = new PredefinedResultSet( 460 [ "a", "b" ], 461 [ Row([ "1", "2" ]) ] 462 ); 463 464 SomeStruct s; 465 populateFromDbRow(s, rs.front); 466 467 assert(s.a == 1); 468 assert(s.b == 2); 469 } 470 /++ 471 Gets all the children of that type. Specifically, it looks in T for a ForeignKey referencing B and queries on that. 472 473 To do a join through a many-to-many relationship, you could get the children of the join table, then get the children of that... 474 Or better yet, use real sql. This is more intended to get info where there is one parent row and then many child 475 rows, not for a combined thing. 476 +/ 477 QueryBuilderHelper!(T[]) children(T, B)(B base) { 478 int countOfAssociations() { 479 int count = 0; 480 static foreach(memberName; __traits(allMembers, T)) 481 static foreach(attr; __traits(getAttributes, __traits(getMember, T, memberName))) {{ 482 static if(is(attr == ForeignKey!(K, policy), alias K, string policy)) { 483 static if(is(attr.ReferencedTable == B)) 484 count++; 485 } 486 }} 487 return count; 488 } 489 static assert(countOfAssociations() == 1, T.stringof ~ " does not have exactly one foreign key of type " ~ B.stringof); 490 string keyName() { 491 static foreach(memberName; __traits(allMembers, T)) 492 static foreach(attr; __traits(getAttributes, __traits(getMember, T, memberName))) {{ 493 static if(is(attr == ForeignKey!(K, policy), alias K, string policy)) { 494 static if(is(attr.ReferencedTable == B)) 495 return memberName; 496 } 497 }} 498 } 499 500 // return QueryBuilderHelper!(T[])(toTableName(T.stringof)).where!(mixin(keyName ~ " => base.id")); 501 502 // changing mixin cuz of regression in dmd 2.088 503 mixin("return QueryBuilderHelper!(T[])(tableNameFor!T()).where!("~keyName ~ " => base.id);"); 504 } 505 506 /++ 507 Finds the single row associated with a foreign key in `base`. 508 509 `T` is used to find the key, unless ambiguous, in which case you must pass `key`. 510 511 To do a join through a many-to-many relationship, go to [children] or use real sql. 512 +/ 513 T associated(B, T, string key = null)(B base, Database db) { 514 int countOfAssociations() { 515 int count = 0; 516 static foreach(memberName; __traits(allMembers, B)) 517 static foreach(attr; __traits(getAttributes, __traits(getMember, B, memberName))) { 518 static if(is(attr == ForeignKey!(K, policy), alias K, string policy)) { 519 static if(is(attr.ReferencedTable == T)) 520 static if(key is null || key == memberName) 521 count++; 522 } 523 } 524 return count; 525 } 526 527 static if(key is null) { 528 enum coa = countOfAssociations(); 529 static assert(coa != 0, B.stringof ~ " has no association of type " ~ T); 530 static assert(coa == 1, B.stringof ~ " has multiple associations of type " ~ T ~ "; please specify the key you want"); 531 static foreach(memberName; __traits(allMembers, B)) 532 static foreach(attr; __traits(getAttributes, __traits(getMember, B, memberName))) { 533 static if(is(attr == ForeignKey!(K, policy), alias K, string policy)) { 534 static if(is(attr.ReferencedTable == T)) 535 return db.find!T(__traits(getMember, base, memberName)); 536 } 537 } 538 } else { 539 static assert(countOfAssociations() == 1, B.stringof ~ " does not have a key named " ~ key ~ " of type " ~ T); 540 static foreach(attr; __traits(getAttributes, __traits(getMember, B, memberName))) { 541 static if(is(attr == ForeignKey!(K, policy), alias K, string policy)) { 542 static if(is(attr.ReferencedTable == T)) { 543 return db.find!T(__traits(getMember, base, key)); 544 } 545 } 546 } 547 assert(0); 548 } 549 } 550 551 552 /++ 553 It will return an aggregate row with a member of type of each table in the join. 554 555 Could do an anonymous object for other things in the sql... 556 +/ 557 auto join(TableA, TableB, ThroughTable = void)() {} 558 559 /++ 560 561 +/ 562 struct QueryBuilderHelper(T) { 563 static if(is(T == R[], R)) 564 alias TType = R; 565 else 566 alias TType = T; 567 568 SelectBuilder selectBuilder; 569 570 this(string tableName) { 571 selectBuilder = new SelectBuilder(); 572 selectBuilder.table = tableName; 573 selectBuilder.fields = ["*"]; 574 } 575 576 T execute(Database db) { 577 selectBuilder.db = db; 578 static if(is(T == R[], R)) { 579 580 } else { 581 selectBuilder.limit = 1; 582 } 583 584 T ret; 585 bool first = true; 586 foreach(row; db.query(selectBuilder.toString())) { 587 TType t; 588 populateFromDbRow(t, row); 589 590 static if(is(T == R[], R)) 591 ret ~= t; 592 else { 593 if(first) { 594 ret = t; 595 first = false; 596 } else { 597 assert(0); 598 } 599 } 600 } 601 return ret; 602 } 603 604 /// 605 typeof(this) orderBy(string criterion)() { 606 string name() { 607 int idx = 0; 608 while(idx < criterion.length && criterion[idx] != ' ') 609 idx++; 610 return criterion[0 .. idx]; 611 } 612 613 string direction() { 614 int idx = 0; 615 while(idx < criterion.length && criterion[idx] != ' ') 616 idx++; 617 import std.string; 618 return criterion[idx .. $].strip; 619 } 620 621 static assert(is(typeof(__traits(getMember, TType, name()))), TType.stringof ~ " has no field " ~ name()); 622 static assert(direction().length == 0 || direction() == "ASC" || direction() == "DESC", "sort direction must be empty, ASC, or DESC"); 623 624 selectBuilder.orderBys ~= criterion; 625 return this; 626 } 627 } 628 629 QueryBuilderHelper!(T[]) from(T)() { 630 return QueryBuilderHelper!(T[])(tableNameFor!T()); 631 } 632 633 /// ditto 634 template where(conditions...) { 635 Qbh where(Qbh)(Qbh this_, string[] sqlCondition...) { 636 assert(this_.selectBuilder !is null); 637 638 static string extractName(string s) { 639 if(s.length == 0) assert(0); 640 auto i = s.length - 1; 641 while(i) { 642 if(s[i] == ')') { 643 // got to close paren, now backward to non-identifier char to get name 644 auto end = i; 645 while(i) { 646 if(s[i] == ' ') 647 return s[i + 1 .. end]; 648 i--; 649 } 650 assert(0); 651 } 652 i--; 653 } 654 assert(0); 655 } 656 657 static foreach(idx, cond; conditions) {{ 658 // I hate this but __parameters doesn't work here for some reason 659 // see my old thread: https://forum.dlang.org/post/awjuoemsnmxbfgzhgkgx@forum.dlang.org 660 enum name = extractName(typeof(cond!int).stringof); 661 auto value = cond(null); 662 663 // FIXME: convert the value as necessary 664 static if(is(typeof(value) == Serial)) 665 auto dbvalue = value.value; 666 else static if(is(typeof(value) == enum)) 667 auto dbvalue = cast(int) value; 668 else 669 auto dbvalue = value; 670 671 import std.conv; 672 673 static assert(is(typeof(__traits(getMember, Qbh.TType, name))), Qbh.TType.stringof ~ " has no member " ~ name); 674 static if(is(typeof(__traits(getMember, Qbh.TType, name)) == int)) { 675 static if(is(typeof(value) : const(int)[])) { 676 string s; 677 foreach(v; value) { 678 if(s.length) s ~= ", "; 679 s ~= to!string(v); 680 } 681 this_.selectBuilder.wheres ~= name ~ " IN (" ~ s ~ ")"; 682 } else { 683 static assert(is(typeof(value) : const(int)) || is(typeof(value) == Serial), Qbh.TType.stringof ~ " is a integer key, but you passed an incompatible " ~ typeof(value).stringof); 684 685 auto placeholder = "?_internal" ~ to!string(idx); 686 this_.selectBuilder.wheres ~= name ~ " = " ~ placeholder; 687 this_.selectBuilder.setVariable(placeholder, dbvalue); 688 } 689 } else static if(is(typeof(__traits(getMember, Qbh.TType, name)) == Nullable!int)) { 690 static if(is(typeof(value) : const(int)[])) { 691 string s; 692 foreach(v; value) { 693 if(s.length) s ~= ", "; 694 s ~= to!string(v); 695 } 696 this_.selectBuilder.wheres ~= name ~ " IN (" ~ s ~ ")"; 697 } else { 698 static assert(is(typeof(value) : const(int)) || is(typeof(value) == Serial), Qbh.TType.stringof ~ " is a integer key, but you passed an incompatible " ~ typeof(value).stringof); 699 700 auto placeholder = "?_internal" ~ to!string(idx); 701 this_.selectBuilder.wheres ~= name ~ " = " ~ placeholder; 702 this_.selectBuilder.setVariable(placeholder, dbvalue); 703 } 704 } else static if(is(typeof(__traits(getMember, Qbh.TType, name)) == Serial)) { 705 static if(is(typeof(value) : const(int)[])) { 706 string s; 707 foreach(v; value) { 708 if(s.length) s ~= ", "; 709 s ~= to!string(v); 710 } 711 this_.selectBuilder.wheres ~= name ~ " IN (" ~ s ~ ")"; 712 } else { 713 static assert(is(typeof(value) : const(int)) || is(typeof(value) == Serial), Qbh.TType.stringof ~ " is a integer key, but you passed an incompatible " ~ typeof(value).stringof); 714 715 auto placeholder = "?_internal" ~ to!string(idx); 716 this_.selectBuilder.wheres ~= name ~ " = " ~ placeholder; 717 this_.selectBuilder.setVariable(placeholder, dbvalue); 718 } 719 720 721 } else { 722 static assert(is(typeof(__traits(getMember, Qbh.TType, name)) == typeof(value)), Qbh.TType.stringof ~ "." ~ name ~ " is not of type " ~ typeof(value).stringof); 723 724 auto placeholder = "?_internal" ~ to!string(idx); 725 this_.selectBuilder.wheres ~= name ~ " = " ~ placeholder; 726 this_.selectBuilder.setVariable(placeholder, dbvalue); 727 } 728 }} 729 730 this_.selectBuilder.wheres ~= sqlCondition; 731 return this_; 732 } 733 }