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