1 module libpb.driver; 2 3 import std.json; 4 import std.stdio; 5 import std.net.curl; 6 import std.conv : to; 7 import std.string : cmp; 8 import libpb.exceptions; 9 import jstruct : fromJSON, SerializationError, serializeRecord; 10 11 12 private mixin template AuthTokenHeader(alias http, PocketBase pbInstance) 13 { 14 // Must be an instance of HTTP from `std.curl` 15 static assert(__traits(isSame, typeof(http), HTTP)); 16 17 void InitializeAuthHeader() 18 { 19 // Check if the given PocketBase instance as an authToken 20 if(pbInstance.authToken.length > 0) 21 { 22 // Then add the authaorization header 23 http.addRequestHeader("Authorization", pbInstance.getAuthToken()); 24 } 25 } 26 27 } 28 29 public class PocketBase 30 { 31 private string pocketBaseURL; 32 private string authToken; 33 34 /** 35 * Constructs a new PocketBase instance with 36 * the default settings 37 */ 38 this(string pocketBaseURL = "http://127.0.0.1:8090/api/", string authToken = "") 39 { 40 this.pocketBaseURL = pocketBaseURL; 41 this.authToken = authToken; 42 } 43 44 public void setAuthToken(string authToken) 45 { 46 if(cmp(authToken, "") != 0) 47 { 48 this.authToken = authToken; 49 } 50 } 51 52 public string getAuthToken() 53 { 54 return this.authToken; 55 } 56 57 /** 58 * List all of the records in the given table (base collection) 59 * 60 * Params: 61 * table = the table to list from 62 * page = the page to look at (default is 1) 63 * perPage = the number of items to return per page (default is 30) 64 * filter = the predicate to filter by 65 * 66 * Returns: A list of type <code>RecordType</code> 67 */ 68 public RecordType[] listRecords(RecordType)(string table, ulong page = 1, ulong perPage = 30, string filter = "") 69 { 70 return listRecords_internal!(RecordType)(table, page, perPage, filter, false); 71 } 72 73 /** 74 * List all of the records in the given table (auth collection) 75 * 76 * Params: 77 * table = the table to list from 78 * page = the page to look at (default is 1) 79 * perPage = the number of items to return per page (default is 30) 80 * filter = the predicate to filter by 81 * 82 * Returns: A list of type <code>RecordType</code> 83 */ 84 public RecordType[] listRecordsAuth(RecordType)(string table, ulong page = 1, ulong perPage = 30, string filter = "") 85 { 86 return listRecords_internal!(RecordType)(table, page, perPage, filter, true); 87 } 88 89 /** 90 * List all of the records in the given table (internals) 91 * 92 * Params: 93 * table = the table to list from 94 * page = the page to look at (default is 1) 95 * perPage = the number of items to return per page (default is 30) 96 * filter = the predicate to filter by 97 * isAuthCollection = true if this is an auth collection, false 98 * for base collection 99 * 100 * Returns: A list of type <code>RecordType</code> 101 */ 102 private RecordType[] listRecords_internal(RecordType)(string table, ulong page, ulong perPage, string filter, bool isAuthCollection) 103 { 104 // Set authorization token if setup 105 HTTP httpSettings = HTTP(); 106 mixin AuthTokenHeader!(httpSettings, this); 107 InitializeAuthHeader(); 108 109 RecordType[] recordsOut; 110 111 // Compute the query string 112 string queryStr = "page="~to!(string)(page)~"&perPage="~to!(string)(perPage); 113 114 // If there is a filter then perform the needed escaping 115 if(cmp(filter, "") != 0) 116 { 117 // For the filter, make sure to add URL escaping to the `filter` parameter 118 import etc.c.curl : curl_escape; 119 import std.string : toStringz, fromStringz; 120 char* escapedParameter = curl_escape(toStringz(filter), cast(int)filter.length); 121 if(escapedParameter is null) 122 { 123 debug(dbg) 124 { 125 writeln("Invalid return from curl_easy_escape"); 126 } 127 throw new NetworkException(); 128 } 129 130 // Convert back to D-string (the filter) 131 filter = cast(string)fromStringz(escapedParameter); 132 } 133 134 // Append the filter 135 queryStr ~= cmp(filter, "") == 0 ? "" : "&filter="~filter; 136 137 try 138 { 139 string responseData = cast(string)get(pocketBaseURL~"collections/"~table~"/records?"~queryStr, httpSettings); 140 JSONValue responseJSON = parseJSON(responseData); 141 JSONValue[] returnedItems = responseJSON["items"].array(); 142 143 foreach(JSONValue returnedItem; returnedItems) 144 { 145 // If this is an authable record (meaning it has email, password and passwordConfirm) 146 // well then the latter two will not be returned so fill them in. Secondly, the email 147 // will only be returned if `emailVisibility` is true. 148 if(isAuthCollection) 149 { 150 returnedItem["password"] = ""; 151 returnedItem["passwordConfirm"] = ""; 152 153 // If email is invisible make a fake field to prevent crash 154 if(!returnedItem["emailVisibility"].boolean()) 155 { 156 returnedItem["email"] = ""; 157 } 158 } 159 160 recordsOut ~= fromJSON!(RecordType)(returnedItem); 161 } 162 163 return recordsOut; 164 } 165 catch(HTTPStatusException e) 166 { 167 if(e.status == 403) 168 { 169 throw new NotAuthorized(table, null); 170 } 171 else 172 { 173 throw new NetworkException(); 174 } 175 } 176 catch(CurlException e) 177 { 178 debug(dbg) 179 { 180 writeln("curl"); 181 writeln(e); 182 } 183 184 throw new NetworkException(); 185 } 186 catch(JSONException e) 187 { 188 throw new PocketBaseParsingException(); 189 } 190 catch(SerializationError e) 191 { 192 throw new RemoteFieldMissing(); 193 } 194 } 195 196 /** 197 * Creates a record in the given authentication table 198 * 199 * Params: 200 * table = the table to create the record in 201 * item = The Record to create 202 * 203 * Returns: An instance of the created <code>RecordType</code> 204 */ 205 public RecordType createRecordAuth(string, RecordType)(string table, RecordType item) 206 { 207 mixin isAuthable!(RecordType); 208 209 return createRecord_internal(table, item, true); 210 } 211 212 /** 213 * Creates a record in the given base table 214 * 215 * Params: 216 * table = the table to create the record in 217 * item = The Record to create 218 * 219 * Returns: An instance of the created <code>RecordType</code> 220 */ 221 public RecordType createRecord(string, RecordType)(string table, RecordType item) 222 { 223 return createRecord_internal(table, item, false); 224 } 225 226 /** 227 * Creates a record in the given table (internal method) 228 * 229 * Params: 230 * table = the table to create the record in 231 * item = The Record to create 232 * isAuthCollection = whether or not this collection is auth or not (base) 233 * 234 * Returns: An instance of the created <code>RecordType</code> 235 */ 236 private RecordType createRecord_internal(string, RecordType)(string table, RecordType item, bool isAuthCollection) 237 { 238 idAbleCheck(item); 239 240 RecordType recordOut; 241 242 // Set authorization token if setup 243 HTTP httpSettings = HTTP(); 244 mixin AuthTokenHeader!(httpSettings, this); 245 InitializeAuthHeader(); 246 247 // Set the content type 248 httpSettings.addRequestHeader("Content-Type", "application/json"); 249 250 // Serialize the record instance 251 JSONValue serialized = serializeRecord(item); 252 253 try 254 { 255 string responseData = cast(string)post(pocketBaseURL~"collections/"~table~"/records", serialized.toString(), httpSettings); 256 JSONValue responseJSON = parseJSON(responseData); 257 258 // On creation of a record in an "auth" collection the email visibility 259 // will initially be false, therefore fill in a blank for it temporarily 260 // now as to not make `fromJSON` crash when it sees an email field in 261 // a struct and tries to look the the JSON key "email" when it isn't present 262 // 263 // A password is never returned (so `password` and `passwordConfirm` will be left out) 264 // 265 // The above are all assumed to be strings, if not then a runtime error will occur 266 // See (issue #3) 267 if(isAuthCollection) 268 { 269 responseJSON["email"] = ""; 270 responseJSON["password"] = ""; 271 responseJSON["passwordConfirm"] = ""; 272 } 273 274 recordOut = fromJSON!(RecordType)(responseJSON); 275 276 return recordOut; 277 } 278 catch(HTTPStatusException e) 279 { 280 debug(dbg) 281 { 282 writeln("createRecord_internal: "~e.toString()); 283 } 284 285 if(e.status == 403) 286 { 287 throw new NotAuthorized(table, item.id); 288 } 289 else if(e.status == 400) 290 { 291 throw new ValidationRequired(table, item.id); 292 } 293 else 294 { 295 // TODO: Fix this 296 throw new NetworkException(); 297 } 298 } 299 catch(CurlException e) 300 { 301 throw new NetworkException(); 302 } 303 catch(JSONException e) 304 { 305 throw new PocketBaseParsingException(); 306 } 307 catch(SerializationError e) 308 { 309 throw new RemoteFieldMissing(); 310 } 311 } 312 313 /** 314 * Authenticates on the given auth table with the provided 315 * credentials, returning a JWT token in the reference parameter. 316 * Finally returning the record of the authenticated user. 317 * 318 * Params: 319 * table = the auth collection to use 320 * identity = the user's identity 321 * password = the user's password 322 * token = the variable to return into 323 * 324 * Returns: An instance of `RecordType` 325 */ 326 public RecordType authWithPassword(RecordType)(string table, string identity, string password, ref string token) 327 { 328 mixin isAuthable!(RecordType); 329 330 RecordType recordOut; 331 332 // Set the content type 333 HTTP httpSettings = HTTP(); 334 httpSettings.addRequestHeader("Content-Type", "application/json"); 335 336 // Construct the authentication record 337 JSONValue authRecord; 338 authRecord["identity"] = identity; 339 authRecord["password"] = password; 340 341 try 342 { 343 string responseData = cast(string)post(pocketBaseURL~"collections/"~table~"/auth-with-password", authRecord.toString(), httpSettings); 344 JSONValue responseJSON = parseJSON(responseData); 345 JSONValue recordResponse = responseJSON["record"]; 346 347 // In the case we are doing auth, we won't get password, passwordConfirm sent back 348 // set them to empty 349 recordResponse["password"] = ""; 350 recordResponse["passwordConfirm"] = ""; 351 352 // If email is invisible make a fake field to prevent crash 353 if(!recordResponse["emailVisibility"].boolean()) 354 { 355 recordResponse["email"] = ""; 356 } 357 358 recordOut = fromJSON!(RecordType)(recordResponse); 359 360 // Store the token 361 token = responseJSON["token"].str(); 362 363 return recordOut; 364 } 365 catch(HTTPStatusException e) 366 { 367 if(e.status == 400) 368 { 369 // TODO: Update this error 370 throw new NotAuthorized(table, null); 371 } 372 else 373 { 374 // TODO: Fix this 375 throw new NetworkException(); 376 } 377 } 378 catch(CurlException e) 379 { 380 throw new NetworkException(); 381 } 382 catch(JSONException e) 383 { 384 throw new PocketBaseParsingException(); 385 } 386 catch(SerializationError e) 387 { 388 throw new RemoteFieldMissing(); 389 } 390 } 391 392 /** 393 * View the given record by id (base collections) 394 * 395 * Params: 396 * table = the table to lookup the record in 397 * id = the id to lookup the record by 398 * 399 * Returns: The found record of type <code>RecordType</code> 400 */ 401 public RecordType viewRecord(RecordType)(string table, string id) 402 { 403 return viewRecord_internal!(RecordType)(table, id, false); 404 } 405 406 407 /** 408 * View the given record by id (auth collections) 409 * 410 * Params: 411 * table = the table to lookup the record in 412 * id = the id to lookup the record by 413 * 414 * Returns: The found record of type <code>RecordType</code> 415 */ 416 public RecordType viewRecordAuth(RecordType)(string table, string id) 417 { 418 return viewRecord_internal!(RecordType)(table, id, true); 419 } 420 421 /** 422 * View the given record by id (internal) 423 * 424 * Params: 425 * table = the table to lookup the record in 426 * id = the id to lookup the record by 427 * isAuthCollection = true if this is an auth collection, false 428 * for base collection 429 * 430 * Returns: The found record of type <code>RecordType</code> 431 */ 432 private RecordType viewRecord_internal(RecordType)(string table, string id, bool isAuthCollection) 433 { 434 RecordType recordOut; 435 436 // Set authorization token if setup 437 HTTP httpSettings = HTTP(); 438 mixin AuthTokenHeader!(httpSettings, this); 439 InitializeAuthHeader(); 440 441 try 442 { 443 string responseData = cast(string)get(pocketBaseURL~"collections/"~table~"/records/"~id, httpSettings); 444 JSONValue responseJSON = parseJSON(responseData); 445 446 // If this is an authable record (meaning it has email, password and passwordConfirm) 447 // well then the latter two will not be returned so fill them in. Secondly, the email 448 // will only be returned if `emailVisibility` is true. 449 if(isAuthCollection) 450 { 451 responseJSON["password"] = ""; 452 responseJSON["passwordConfirm"] = ""; 453 454 // If email is invisible make a fake field to prevent crash 455 if(!responseJSON["emailVisibility"].boolean()) 456 { 457 responseJSON["email"] = ""; 458 } 459 } 460 461 recordOut = fromJSON!(RecordType)(responseJSON); 462 463 return recordOut; 464 } 465 catch(HTTPStatusException e) 466 { 467 if(e.status == 404) 468 { 469 throw new RecordNotFoundException(table, id); 470 } 471 else 472 { 473 // TODO: Fix this 474 throw new NetworkException(); 475 } 476 } 477 catch(CurlException e) 478 { 479 throw new NetworkException(); 480 } 481 catch(JSONException e) 482 { 483 throw new PocketBaseParsingException(); 484 } 485 catch(SerializationError e) 486 { 487 throw new RemoteFieldMissing(); 488 } 489 } 490 491 /** 492 * Updates the given record in the given table, returning the 493 * updated record (auth collections) 494 * 495 * Params: 496 * table = tabe table to update the record in 497 * item = the record of type <code>RecordType</code> to update 498 * 499 * Returns: The updated <code>RecordType</code> 500 */ 501 public RecordType updateRecordAuth(string, RecordType)(string table, RecordType item) 502 { 503 return updateRecord_internal(table, item, true); 504 } 505 506 /** 507 * Updates the given record in the given table, returning the 508 * updated record (base collections) 509 * 510 * Params: 511 * table = tabe table to update the record in 512 * item = the record of type <code>RecordType</code> to update 513 * 514 * Returns: The updated <code>RecordType</code> 515 */ 516 public RecordType updateRecord(string, RecordType)(string table, RecordType item) 517 { 518 return updateRecord_internal(table, item, false); 519 } 520 521 /** 522 * Updates the given record in the given table, returning the 523 * updated record (internal) 524 * 525 * Params: 526 * table = tabe table to update the record in 527 * item = the record of type <code>RecordType</code> to update 528 * isAuthCollection = true if this is an auth collection, false 529 * for base collection 530 * 531 * Returns: The updated <code>RecordType</code> 532 */ 533 private RecordType updateRecord_internal(string, RecordType)(string table, RecordType item, bool isAuthCollection) 534 { 535 idAbleCheck(item); 536 537 RecordType recordOut; 538 539 // Set authorization token if setup 540 HTTP httpSettings = HTTP(); 541 mixin AuthTokenHeader!(httpSettings, this); 542 InitializeAuthHeader(); 543 544 // Set the content type 545 httpSettings.addRequestHeader("Content-Type", "application/json"); 546 547 // Serialize the record instance 548 JSONValue serialized = serializeRecord(item); 549 550 try 551 { 552 string responseData = cast(string)patch(pocketBaseURL~"collections/"~table~"/records/"~item.id, serialized.toString(), httpSettings); 553 JSONValue responseJSON = parseJSON(responseData); 554 555 // If this is an authable record (meaning it has email, password and passwordConfirm) 556 // well then the latter two will not be returned so fill them in. Secondly, the email 557 // will only be returned if `emailVisibility` is true. 558 if(isAuthCollection) 559 { 560 responseJSON["password"] = ""; 561 responseJSON["passwordConfirm"] = ""; 562 563 // If email is invisible make a fake field to prevent crash 564 if(!responseJSON["emailVisibility"].boolean()) 565 { 566 responseJSON["email"] = ""; 567 } 568 } 569 570 recordOut = fromJSON!(RecordType)(responseJSON); 571 572 return recordOut; 573 } 574 catch(HTTPStatusException e) 575 { 576 if(e.status == 404) 577 { 578 throw new RecordNotFoundException(table, item.id); 579 } 580 else if(e.status == 403) 581 { 582 throw new NotAuthorized(table, item.id); 583 } 584 else if(e.status == 400) 585 { 586 throw new ValidationRequired(table, item.id); 587 } 588 else 589 { 590 // TODO: Fix this 591 throw new NetworkException(); 592 } 593 } 594 catch(CurlException e) 595 { 596 throw new NetworkException(); 597 } 598 catch(JSONException e) 599 { 600 throw new PocketBaseParsingException(); 601 } 602 catch(SerializationError e) 603 { 604 throw new RemoteFieldMissing(); 605 } 606 } 607 608 /** 609 * Deletes the provided record by id from the given table 610 * 611 * Params: 612 * table = the table to delete the record from 613 * id = the id of the record to delete 614 */ 615 public void deleteRecord(string table, string id) 616 { 617 // Set authorization token if setup 618 HTTP httpSettings = HTTP(); 619 mixin AuthTokenHeader!(httpSettings, this); 620 InitializeAuthHeader(); 621 622 try 623 { 624 del(pocketBaseURL~"collections/"~table~"/records/"~id, httpSettings); 625 } 626 catch(HTTPStatusException e) 627 { 628 if(e.status == 404) 629 { 630 throw new RecordNotFoundException(table, id); 631 } 632 else 633 { 634 // TODO: Fix this 635 throw new NetworkException(); 636 } 637 } 638 catch(CurlException e) 639 { 640 throw new NetworkException(); 641 } 642 } 643 644 /** 645 * Deletes the provided record from the given table 646 * 647 * Params: 648 * table = the table to delete from 649 * record = the record of type <code>RecordType</code> to delete 650 */ 651 public void deleteRecord(string, RecordType)(string table, RecordType record) 652 { 653 idAbleCheck(record); 654 deleteRecord(table, record.id); 655 } 656 657 private mixin template MemberAndType(alias record, alias typeEnforce, string memberName) 658 { 659 static if(__traits(hasMember, record, memberName)) 660 { 661 static if(__traits(isSame, typeof(mixin("record."~memberName)), typeEnforce)) 662 { 663 664 } 665 else 666 { 667 pragma(msg, "Member '"~memberName~"' not of type '"~typeEnforce~"'"); 668 static assert(false); 669 } 670 } 671 else 672 { 673 pragma(msg, "Record does not have member '"~memberName~"'"); 674 static assert(false); 675 } 676 } 677 678 private static void isAuthable(RecordType)(RecordType record) 679 { 680 mixin MemberAndType!(record, string, "email"); 681 mixin MemberAndType!(record, string, "password"); 682 mixin MemberAndType!(record, string, "passwordConfirm"); 683 } 684 685 private static void idAbleCheck(RecordType)(RecordType record) 686 { 687 static if(__traits(hasMember, record, "id")) 688 { 689 static if(__traits(isSame, typeof(record.id), string)) 690 { 691 // Do nothing as it is a-okay 692 } 693 else 694 { 695 // Must be a string 696 pragma(msg, "The `id` field of the record provided must be of type string"); 697 static assert(false); 698 } 699 } 700 else 701 { 702 // An id field is required (TODO: ensure not a function identifier) 703 pragma(msg, "The provided record must have a `id` field"); 704 static assert(false); 705 } 706 } 707 708 // TODO: Implement the streaming functionality 709 private void stream(string table) 710 { 711 712 } 713 } 714 715 unittest 716 { 717 import core.thread : Thread, dur; 718 import std.string : cmp; 719 720 PocketBase pb = new PocketBase(); 721 722 struct Person 723 { 724 string id; 725 string name; 726 int age; 727 } 728 729 Person p1 = Person(); 730 p1.name = "Tristan Gonzales"; 731 p1.age = 23; 732 733 Person recordStored = pb.createRecord("dummy", p1); 734 pb.deleteRecord("dummy", recordStored.id); 735 736 737 recordStored = pb.createRecord("dummy", p1); 738 Thread.sleep(dur!("seconds")(3)); 739 recordStored.age = 46; 740 recordStored = pb.updateRecord("dummy", recordStored); 741 assert(recordStored.age == 46); 742 Thread.sleep(dur!("seconds")(3)); 743 744 Person recordFetched = pb.viewRecord!(Person)("dummy", recordStored.id); 745 assert(recordFetched.age == 46); 746 assert(cmp(recordFetched.name, "Tristan Gonzales") == 0); 747 assert(cmp(recordFetched.id, recordStored.id) == 0); 748 749 pb.deleteRecord("dummy", recordStored); 750 751 Person[] people = [Person(), Person()]; 752 people[0].name = "Abby"; 753 people[1].name = "Becky"; 754 755 people[0] = pb.createRecord("dummy", people[0]); 756 people[1] = pb.createRecord("dummy", people[1]); 757 758 Person[] returnedPeople = pb.listRecords!(Person)("dummy"); 759 foreach(Person returnedPerson; returnedPeople) 760 { 761 debug(dbg) 762 { 763 writeln(returnedPerson); 764 } 765 pb.deleteRecord("dummy", returnedPerson); 766 } 767 768 try 769 { 770 recordFetched = pb.viewRecord!(Person)("dummy", people[0].id); 771 assert(false); 772 } 773 catch(RecordNotFoundException e) 774 { 775 assert(cmp(e.offendingTable, "dummy") == 0 && e.offendingId == people[0].id); 776 } 777 catch(Exception e) 778 { 779 assert(false); 780 } 781 782 try 783 { 784 recordFetched = pb.updateRecord("dummy", people[0]); 785 assert(false); 786 } 787 catch(RecordNotFoundException e) 788 { 789 assert(cmp(e.offendingTable, "dummy") == 0 && e.offendingId == people[0].id); 790 } 791 catch(Exception e) 792 { 793 assert(false); 794 } 795 796 try 797 { 798 pb.deleteRecord("dummy", people[0]); 799 assert(false); 800 } 801 catch(RecordNotFoundException e) 802 { 803 assert(cmp(e.offendingTable, "dummy") == 0 && e.offendingId == people[0].id); 804 } 805 catch(Exception e) 806 { 807 assert(false); 808 } 809 } 810 811 unittest 812 { 813 import core.thread : Thread, dur; 814 import std.string : cmp; 815 816 PocketBase pb = new PocketBase(); 817 818 struct Person 819 { 820 string id; 821 string email; 822 string username; 823 string password; 824 string passwordConfirm; 825 string name; 826 int age; 827 } 828 829 // Set the password to use 830 string passwordToUse = "bigbruh1111"; 831 832 Person p1; 833 p1.email = "deavmi@redxen.eu"; 834 p1.username = "deavmi"; 835 p1.password = passwordToUse; 836 p1.passwordConfirm = passwordToUse; 837 p1.name = "Tristaniha"; 838 p1.age = 29; 839 840 p1 = pb.createRecordAuth("dummy_auth", p1); 841 842 843 Person[] people = pb.listRecordsAuth!(Person)("dummy_auth", 1, 30, "(id='"~p1.id~"')"); 844 assert(people.length == 1); 845 846 // Ensure we get our person back 847 assert(cmp(people[0].name, p1.name) == 0); 848 assert(people[0].age == p1.age); 849 // assert(cmp(people[0].email, p1.email) == 0); 850 851 852 Person person = pb.viewRecordAuth!(Person)("dummy_auth", p1.id); 853 854 // Ensure we get our person back 855 assert(cmp(people[0].name, p1.name) == 0); 856 assert(people[0].age == p1.age); 857 // assert(cmp(people[0].email, p1.email) == 0); 858 859 860 string newName = "Bababooey"; 861 person.name = newName; 862 person = pb.updateRecordAuth("dummy_auth", person); 863 assert(cmp(person.name, newName) == 0); 864 865 866 867 string tokenIn; 868 Person authPerson = pb.authWithPassword!(Person)("dummy_auth", p1.username, passwordToUse, tokenIn); 869 870 // Ensure a non-empty token 871 assert(cmp(tokenIn, "") != 0); 872 writeln("Token: "~tokenIn); 873 874 // Ensure we get our person back 875 assert(cmp(authPerson.name, person.name) == 0); 876 assert(authPerson.age == person.age); 877 assert(cmp(authPerson.email, person.email) == 0); 878 879 // Delete the record 880 pb.deleteRecord("dummy_auth", p1); 881 } 882 883 unittest 884 { 885 import core.thread : Thread, dur; 886 import std.string : cmp; 887 888 PocketBase pb = new PocketBase(); 889 890 struct Person 891 { 892 string id; 893 string name; 894 int age; 895 } 896 897 Person p1 = Person(); 898 p1.name = "Tristan Gonzales"; 899 p1.age = 23; 900 901 Person p2 = Person(); 902 p2.name = p1.name~"2"; 903 p2.age = p1.age; 904 905 p1 = pb.createRecord("dummy", p1); 906 p2 = pb.createRecord("dummy", p2); 907 908 Person[] people = pb.listRecords!(Person)("dummy", 1, 30, "(id='"~p1.id~"')"); 909 assert(people.length == 1); 910 assert(cmp(people[0].id, p1.id) == 0); 911 912 pb.deleteRecord("dummy", p1); 913 people = pb.listRecords!(Person)("dummy", 1, 30, "(id='"~p1.id~"')"); 914 assert(people.length == 0); 915 916 people = pb.listRecords!(Person)("dummy", 1, 30, "(id='"~p2.id~"' && age=24)"); 917 assert(people.length == 0); 918 919 people = pb.listRecords!(Person)("dummy", 1, 30, "(id='"~p2.id~"' && age=23)"); 920 assert(people.length == 1 && cmp(people[0].id, p2.id) == 0); 921 922 pb.deleteRecord("dummy", p2); 923 }