pyg.mongo¶
MongoDB has replaced our SQL databases as it is just too much fun to use. MongoDB does have its little quirks:
The MongoDB ‘query document’ that replaces the SQL WHERE statements is very powerful but you need a PhD for even the simplest of queries.
too many objects we use (specifically, numpy and pandas objects) cannot be pushed directly easily into Mongo.
Mongo lacks the concept of a table with primary keys. Unstructured data is great but much of how we think of data is structured.
pyg.mongo addresses all three issues:
q is a much easier way to generate Mongo queries. We are happy to acknowledge TinyDB https://tinydb.readthedocs.io/en/latest/usage.html#queries for the idea.
mongo_cursor is a super-charged cursor and in particular, it handles encoding and decoding of objects seemlessly in a way that allows us to store all that we want in Mongo.
mongo_pk_cursor manages a table with primary keys and full history audit. We are happy to acknowledge Arctic by the AHL Man team for the initial inspiration
q¶
The MongoDB interface for query of a collection (table) is via a creation of a query document https://docs.mongodb.com/manual/tutorial/query-documents/. This is rather complicated for the average use. For example, if you wanted to locate James Bond in the collection, you would need to compose q query document that looks like this:
[1]:
{"$and": [{"name": {"$eq": "James"}}, {"surname": {"$eq": "Bond"}}]}
[1]:
{'$and': [{'name': {'$eq': 'James'}}, {'surname': {'$eq': 'Bond'}}]}
It’s doable, but not much fun writing. Luckily… within the continuum you can write this instead:
[2]:
from pyg import *; import re
q(name = 'James', surname = 'Bond')
[2]:
{"$and": [{"name": {"$eq": "James"}}, {"surname": {"$eq": "Bond"}}]}
[3]:
(q.name == 'James') & (q.surname == 'Bond')
[3]:
{"$and": [{"name": {"$eq": "James"}}, {"surname": {"$eq": "Bond"}}]}
How do we create in MongoDB a query document to find all the James who are not Bond?
[4]:
(q.surname!='Bond') & (q.name == 'James')
[4]:
{"$and": [{"name": {"$eq": "James"}}, {"surname": {"$ne": "Bond"}}]}
[5]:
~(q.surname=='Bond') & (q.name == 'James')
[5]:
{"$and": [{"$not": {"surname": {"$eq": "Bond"}}}, {"name": {"$eq": "James"}}]}
What about records with no surname?
[6]:
(q.name == 'James') - q.surname
[6]:
{"$and": [{"name": {"$eq": "James"}}, {"surname": {"$exists": false}}]}
[7]:
q(q.surname.not_exists, name = 'James')
[7]:
{"$and": [{"name": {"$eq": "James"}}, {"surname": {"$exists": false}}]}
And what about records with james rather than James?
[8]:
q(name = ['james', 'James'], surname = ['bond', 'Bond']) ## the result is long so it is represented more nicely...
[8]:
$and:
[{"name": {"$in": ["james", "James"]}}, {"surname": {"$in": ["bond", "Bond"]}}]
[9]:
q(name = re.compile('^[J|j]ames'), surname = re.compile('^[B|b]ond'))
[9]:
$and:
[{"name": {"regex": "^[J|j]ames"}}, {"surname": {"regex": "^[B|b]ond"}}]
As you can see, q is callable and you can put expressions inside it, or you can use the q.key method.
If you have funny characters or spaces in your dict…
[10]:
q['funny$text with # weird £ characters'].exists
[10]:
{"funny$text with # weird £ characters": {"$exists": true}}
If your document is nested and there are subkeys, that is ok, you can use either:
[11]:
(q['key.subkey']>=100) | ((q.key.other.exists) & (q.some.other.stuff == [1,2]))
[11]:
$or:
mdict
$and:
[{"key.other": {"$exists": true}}, {"some.other.stuff": {"$in": [1, 2]}}]
M{"key.subkey": {"$gte": 100}}
q does not have the full power of the Mongo query document but it will get you to 95% of what you want. We end with a fun James Bond query. If we want to find the bond films with all actors who played James Bond after 1980…
[12]:
bonds = dictable(name = ['Daniel', 'Sean', 'Roger', 'Timothy'], surname = ['Craig', 'Connery', 'Moore', 'Dalton'])
bonds
[12]:
dictable[4 x 2]
name |surname
Daniel |Craig
Sean |Connery
Roger |Moore
Timothy|Dalton
[13]:
q(list(bonds), q.release_date > dt(1980))
[13]:
$and:
mdict
$or:
M{"$and": [{"name": {"$eq": "Daniel"}}, {"surname": {"$eq": "Craig"}}]}
M{"$and": [{"name": {"$eq": "Roger"}}, {"surname": {"$eq": "Moore"}}]}
M{"$and": [{"name": {"$eq": "Sean"}}, {"surname": {"$eq": "Connery"}}]}
M{"$and": [{"name": {"$eq": "Timothy"}}, {"surname": {"$eq": "Dalton"}}]}
M{"release_date": {"$gt": datetime.datetime(1980, 1, 1, 0, 0)}}
mongo_cursor¶
The mongo cursor:
enables saving seemlessly objects and data in MongoDB
simplifies filtering
simplifies projecting onto certain keys in document
general objects insertion into documents¶
pymongo.Collection supports insertion of documents into it:
[14]:
from pyg import *; import pymongo as pym; import pytest
c = pym.MongoClient()['test']['test']
c.drop() # drop all documents
c.insert_one(dict(a = 1, b = 2)) # insert a document
[14]:
<pymongo.results.InsertOneResult at 0x2d26bf25280>
[15]:
assert c.count_documents({}) == 1 # in order to count documents, must apply the empty query document {}
We can do similar stuff with a mongo_cursor:
[16]:
t = mongo_table(table = 'test', db = 'test')
t.drop()
t.insert_one(dict(a = 1, b = 2))
2021-03-07 20:42:47,719 - pyg - INFO - INFO: deleting 1 documents based on M{}
[16]:
{'a': 1, 'b': 2, '_id': ObjectId('60453ac70e096da27d7d20bf')}
[17]:
assert len(t) == 1 #no need to specify the filter, mongo_cursor keeps track of the current filter
Annoyingly, raw pymongo.Collection cannot encode for lots of existing objects.
[18]:
ts = pd.Series([1.,2.], drange(2000,1))
a = np.arange(3)
f = np.float32(32.0)
with pytest.raises(Exception):
c.insert_one(dict(a = a)) # cannot insert an array
with pytest.raises(Exception):
c.insert_one(dict(f = f)) # cannot insert a numpy float, string or bool
with pytest.raises(Exception):
c.insert_one(dict(ts = ts)) # cannot insert a pd.Series or DataFrame
Further, unless we define the encoding, new classes do not work either
[19]:
class NewClass():
def __init__(self, n):
self.n = n
def __eq__(self, other):
return type(other) == type(self) and self.n == other.n
n = NewClass(1)
with pytest.raises(Exception):
c.insert_one(dict(n = n))
Luckily, the mongo_cursor t can insert all these happily:
[20]:
t.drop()
t.insert_one(Dict(a = a, f = f, ts = ts, n = n))
assert len(t) == 1
t[0] ## reading it back
2021-03-07 20:42:47,836 - pyg - INFO - INFO: deleting 1 documents based on M{}
[20]:
{'_id': ObjectId('60453ac70e096da27d7d20c4'),
'a': array([0, 1, 2]),
'f': 32.0,
'ts': 2000-01-01 1.0
2000-01-02 2.0
dtype: float64,
'n': <__main__.NewClass at 0x2d26c6439d0>}
document reading¶
What is nice is that when you read the document using the mongo_cursor, you get back the object you saved, not just the data. Is this magic? Not really… We read the doc directly from the Collection:
[21]:
raw_doc = c.find_one({})
assert raw_doc['n'] == '{"py/object": "__main__.NewClass", "n": 1}'
assert encode(n) == '{"py/object": "__main__.NewClass", "n": 1}'
assert decode('{"py/object": "__main__.NewClass", "n": 1}') == n
assert t.writer == encode
assert t.reader == decode
When writing, the mongo_cursor encodes the objects pre-saving it into Mongo, in this case as a simple dict
When reading, it uses decode to convert what it reads back into the object
This is done transparently though you can have full control via specifying writer/reader functions
This all works with the assumption that the person loading and the person saving share the library so objects can be instantiated on load. If construction method has changed and the object is not back-compatible, then user will receive the undecoded object and a warning message is logged.
document writing to files¶
MongoDB is great for manipulating/searching dict keys/values. The actual dataframes in each doc, we may want to save in a file system because:
DataFrames are stored as bytes in MongoDB anyway, so they are not searchable
MongoDB free version has limitations on size of document
For data licensing issues, data must not sit on servers but needs to be stored on local computer
Storing in files allows other non-python/non-MongoDB users easier access, allowing data to be detached from app. In particular, if you want to stream messages into the array/dataframe, doing it through Mongo is probably the wrong way about it. https://github.com/man-group/arctic attempts to do it but Mongo should probably just contain a reference to a file. You then have a listener such as 0MQ appending new messages into the file (perhaps via https://github.com/xor2k/npy-append-array/ or awswrangler). This will be (a) more performant, (b) require next to no code, and (c) new data will then magically show up in Mongo every time you read the document.
[22]:
t2 = mongo_table('test', 'test', writer = 'parquet')
t2.drop()
doc = dict(root = 'c:/temp', a = [a,a,a], ts = dict(one = ts, two = ts), f = f, n = n) ## can handle lists of arrays or dicts of stuff
t2.insert_one(doc)
encoded = c.find_one({})
print(tree_repr(encoded))
2021-03-07 20:42:47,907 - pyg - INFO - INFO: deleting 1 documents based on M{}
_id:
60453ac90e096da27d7d20c6
root:
c:/temp
a:
{'_obj': '{"py/function": "numpy.load"}', 'file': 'c:/temp/a/0.npy'}
{'_obj': '{"py/function": "numpy.load"}', 'file': 'c:/temp/a/1.npy'}
{'_obj': '{"py/function": "numpy.load"}', 'file': 'c:/temp/a/2.npy'}
ts:
one:
_obj:
{"py/function": "pyg.base._parquet.pd_read_parquet"}
path:
c:/temp/ts/one.parquet
two:
_obj:
{"py/function": "pyg.base._parquet.pd_read_parquet"}
path:
c:/temp/ts/two.parquet
f:
32.0
n:
{"py/object": "__main__.NewClass", "n": 1}
You can see that starting at the root location, the document’s numpy arrays and pandas have been saved to .npy and .parquet files
[23]:
print(tree_repr(decode(encoded)))
_id:
60453ac90e096da27d7d20c6
root:
c:/temp
a:
[array([0, 1, 2]), array([0, 1, 2]), array([0, 1, 2])]
ts:
one:
index
2000-01-01 1.0
2000-01-02 2.0
dtype: float64
two:
index
2000-01-01 1.0
2000-01-02 2.0
dtype: float64
f:
32.0
n:
<__main__.NewClass object at 0x000002D26CAFEC10>
[24]:
np.load('c:/temp/a/2.npy') ## can load data directly
[24]:
array([0, 1, 2])
[25]:
pd_read_parquet('c:/temp/ts/one.parquet')
[25]:
index
2000-01-01 1.0
2000-01-02 2.0
dtype: float64
document access¶
We start by pushing a 10x10 times table into t
[26]:
t.drop()
times_table = (dictable(a = range(10)) * dictable(b = range(10)))(c = lambda a, b: a*b)
t.insert_many(times_table)
2021-03-07 20:42:49,637 - pyg - INFO - INFO: deleting 1 documents based on M{}
[26]:
<class 'pyg.mongo._cursor.mongo_cursor'> for Collection(Database(MongoClient(host=['localhost:27017'], document_class=dict, tz_aware=False, connect=True), 'test'), 'test')
M{} None
documents count: 100
dict_keys(['_id', 'a', 'b', 'c', '_obj'])
filters¶
We now examine how we drill down to the document(s) we want:
[27]:
assert len(t.inc(a = 1)) == 10
assert len(t.exc(a = 1)) == 90
assert isinstance(t.inc(a = 1), mongo_cursor) ## it is chain-able
assert len(t.find(q.a == 1).find(q.b == [1,2,3,4])) == 4
We can use the original collection too but not in a chain-like fashion:
[28]:
spec = q(a = 1, b = [1,2,3,4])
assert c.count_documents(spec) == 4
c.find(spec) # That is OK
with pytest.raises(AttributeError): # not OK, cannot chain queries
c.find(q(a=1)).find(q(b = [1,2,3,4]))
iteration¶
Just like a mongo.Cursor, c.find(spec), t is also iterable over the documents:
[29]:
sum([doc for doc in t.find(a = 1).find(b = [1,2,3,4])], dictable())
[29]:
dictable[4 x 4]
_id |a|b|c
60453ac90e096da27d7d20d2|1|1|1
60453ac90e096da27d7d20d3|1|2|2
60453ac90e096da27d7d20d4|1|3|3
60453ac90e096da27d7d20d5|1|4|4
[30]:
dictable(t.find(a = 1).find(b = [1,2,3,4])) ## or just put a cursor straight into a table
[30]:
dictable[4 x 4]
_id |a|b|c
60453ac90e096da27d7d20d2|1|1|1
60453ac90e096da27d7d20d3|1|2|2
60453ac90e096da27d7d20d4|1|3|3
60453ac90e096da27d7d20d5|1|4|4
[31]:
t.find(a = 1).find(b = [1,2,3,4])[::] ## or simple slicing
[31]:
dictable[4 x 4]
_id |a|b|c
60453ac90e096da27d7d20d2|1|1|1
60453ac90e096da27d7d20d3|1|2|2
60453ac90e096da27d7d20d4|1|3|3
60453ac90e096da27d7d20d5|1|4|4
sorting¶
[32]:
t.sort('c', 'b')[::]
[32]:
dictable[100 x 4]
_id |a|b|c
60453ac90e096da27d7d20c7|0|0|0
60453ac90e096da27d7d20d1|1|0|0
60453ac90e096da27d7d20db|2|0|0
...100 rows...
60453ac90e096da27d7d2129|9|8|72
60453ac90e096da27d7d2120|8|9|72
60453ac90e096da27d7d212a|9|9|81
getitem of a specfic document¶
[33]:
t[dict(a = 7, b = 8)]
[33]:
{'_id': ObjectId('60453ac90e096da27d7d2115'), 'a': 7, 'b': 8, 'c': 56}
column access¶
[34]:
t.b
[34]:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[35]:
assert t.b == t.distinct('b') == c.distinct('b')
In MongoDB the cursor can have a ‘projection’ onto specific columns. In mongo_cursor this is simplified:
[36]:
t[['a', 'b']].find(c = 12)[::]
[36]:
dictable[4 x 3]
_id |a|b
60453ac90e096da27d7d2105|6|2
60453ac90e096da27d7d20f2|4|3
60453ac90e096da27d7d20e9|3|4
60453ac90e096da27d7d20e1|2|6
add/remove columns¶
[37]:
del t['c']
t[::]
[37]:
dictable[100 x 3]
_id |a|b
60453ac90e096da27d7d20c7|0|0
60453ac90e096da27d7d20d1|1|0
60453ac90e096da27d7d20db|2|0
...100 rows...
60453ac90e096da27d7d2116|7|9
60453ac90e096da27d7d2120|8|9
60453ac90e096da27d7d212a|9|9
[38]:
t = t.set(c = 'not very useful but...')
t[::]
[38]:
dictable[100 x 4]
_id |a|b|c
60453ac90e096da27d7d20c7|0|0|not very useful but...
60453ac90e096da27d7d20d1|1|0|not very useful but...
60453ac90e096da27d7d20db|2|0|not very useful but...
...100 rows...
60453ac90e096da27d7d2116|7|9|not very useful but...
60453ac90e096da27d7d2120|8|9|not very useful but...
60453ac90e096da27d7d212a|9|9|not very useful but...
[39]:
t = t.set(c = lambda a, b: a * b) ### more useful
t[::]
[39]:
dictable[100 x 4]
_id |a|b|c
60453ac90e096da27d7d20c7|0|0|0
60453ac90e096da27d7d20d1|1|0|0
60453ac90e096da27d7d20db|2|0|0
...100 rows...
60453ac90e096da27d7d2129|9|8|72
60453ac90e096da27d7d2120|8|9|72
60453ac90e096da27d7d212a|9|9|81
add/remove records¶
[40]:
t.inc(c = 12).drop()
t
2021-03-07 20:42:51,028 - pyg - INFO - INFO: deleting 4 documents based on M{'c': {'$eq': 12}}
[40]:
<class 'pyg.mongo._cursor.mongo_cursor'> for Collection(Database(MongoClient(host=['localhost:27017'], document_class=dict, tz_aware=False, connect=True), 'test'), 'test')
M{} None
documents count: 96
dict_keys(['_id', 'a', 'b', '_obj', 'c'])
[41]:
t = t + dict(a = 2, b = 6, c = 12)
t
[41]:
<class 'pyg.mongo._cursor.mongo_cursor'> for Collection(Database(MongoClient(host=['localhost:27017'], document_class=dict, tz_aware=False, connect=True), 'test'), 'test')
M{} None
documents count: 97
dict_keys(['_id', 'a', 'b', '_obj', 'c'])
[42]:
t = t.inc(c = 12).drop() + times_table.inc(c = 12) ## adding four records at once
t
2021-03-07 20:42:51,073 - pyg - INFO - INFO: deleting 1 documents based on M{'c': {'$eq': 12}}
[42]:
<class 'pyg.mongo._cursor.mongo_cursor'> for Collection(Database(MongoClient(host=['localhost:27017'], document_class=dict, tz_aware=False, connect=True), 'test'), 'test')
M{'c': {'$eq': 12}} None
documents count: 4
dict_keys(['_id', 'a', 'b', 'c', '_obj'])
[43]:
t = t.inc(c = 12).drop().insert_many(times_table.inc(c = 12))
t[::]
2021-03-07 20:42:51,099 - pyg - INFO - INFO: deleting 4 documents based on M{'c': {'$eq': 12}}
[43]:
dictable[4 x 4]
_id |a|b|c
60453acb0e096da27d7d2133|6|2|12
60453acb0e096da27d7d2132|4|3|12
60453acb0e096da27d7d2131|3|4|12
60453acb0e096da27d7d2130|2|6|12
[44]:
t = t.raw ## remove the filter c = 12
t
[44]:
<class 'pyg.mongo._cursor.mongo_cursor'> for Collection(Database(MongoClient(host=['localhost:27017'], document_class=dict, tz_aware=False, connect=True), 'test'), 'test')
M{} None
documents count: 100
dict_keys(['_id', 'a', 'b', '_obj', 'c'])
mongo_pk_table¶
mongo_pk_table is a mongo_cursor implementing a table with primary keys. Suppose we want to have a table of people:
[70]:
from pyg import *; import pymongo as pym; import pytest
t = mongo_table(table = 'test', db = 'test')
c = pym.MongoClient()['test']['test']
pk = mongo_table(table = 'test', db = 'test', pk = ['name', 'surname'])
t.drop()
d = dictable(name = ['alan', 'alan', 'barbara', 'chris'], surname = ['adams', 'jones', 'brown', 'jones'], age = [1,2,3,4])
pk.insert_many(d)
pk[::]
2021-03-07 21:04:34,232 - pyg - INFO - INFO: deleting 8 documents based on M{}
[70]:
dictable[4 x 5]
_id |_pk |age|name |surname
60453fe20e096da27d7d2150|['name', 'surname']|1 |alan |adams
60453fe20e096da27d7d2151|['name', 'surname']|2 |alan |jones
60453fe20e096da27d7d2152|['name', 'surname']|3 |barbara|brown
60453fe20e096da27d7d2153|['name', 'surname']|4 |chris |jones
Now let us suppose a year has passed…
[71]:
pk.set(age = lambda age: age + 1)
pk[::]
[71]:
dictable[4 x 5]
_id |_pk |age|name |surname
60453fe20e096da27d7d2150|['name', 'surname']|2 |alan |adams
60453fe20e096da27d7d2151|['name', 'surname']|3 |alan |jones
60453fe20e096da27d7d2152|['name', 'surname']|4 |barbara|brown
60453fe20e096da27d7d2153|['name', 'surname']|5 |chris |jones
The pk-table actually maintains complete audit trail. Older records are not deleted, they just get ’_deleted’ parameter set for them.
[72]:
print(dictable(c))
_pk |name |_obj |age|_deleted |_id |surname
['name', 'surname']|alan |{"py/type": "pyg.base._dict.Dict"}|2 |None |60453fe20e096da27d7d2150|adams
['name', 'surname']|alan |{"py/type": "pyg.base._dict.Dict"}|3 |None |60453fe20e096da27d7d2151|jones
['name', 'surname']|barbara|{"py/type": "pyg.base._dict.Dict"}|4 |None |60453fe20e096da27d7d2152|brown
['name', 'surname']|chris |{"py/type": "pyg.base._dict.Dict"}|5 |None |60453fe20e096da27d7d2153|jones
['name', 'surname']|alan |{"py/type": "pyg.base._dict.Dict"}|1 |2021-03-07 21:04:34.284000|60453fe20e096da27d7d2154|adams
['name', 'surname']|alan |{"py/type": "pyg.base._dict.Dict"}|2 |2021-03-07 21:04:34.289000|60453fe20e096da27d7d2155|jones
['name', 'surname']|barbara|{"py/type": "pyg.base._dict.Dict"}|3 |2021-03-07 21:04:34.293000|60453fe20e096da27d7d2156|brown
['name', 'surname']|chris |{"py/type": "pyg.base._dict.Dict"}|4 |2021-03-07 21:04:34.298000|60453fe20e096da27d7d2157|jones
You can see pk only looks at records where _deleted does not exist and _pk are set.
[73]:
pk
[73]:
<class 'pyg.mongo._pk_cursor.mongo_pk_cursor'> for Collection(Database(MongoClient(host=['localhost:27017'], document_class=dict, tz_aware=False, connect=True), 'test'), 'test')
M{'$and': [{"_deleted": {"$exists": false}}, {"_pk": {"$eq": ["name", "surname"]}}]} None
documents count: 4
dict_keys(['_id', '_obj', '_pk', 'age', 'name', 'surname'])
There are obvioursly some small differences on how pk works but broadly, it is just like a normal mongo_cursor with an added filter to zoom onto the records that maintain the primary-key table:
you cannot insert docs without primary keys all present:
the drop() command does not actually delete the documents, they are simply ‘marked’ as deleted.
to get from a mongo_pk_cursor to mongo_cursor, simply go pk.raw
[74]:
with pytest.raises(KeyError):
pk.insert_one(dict(no_name_or_surname = 'James')) # cannot insert with no PK
[75]:
pk.drop()
len(pk)
[75]:
0
[76]:
t[::] ## the data is there, it is just marked as _deleted
[76]:
dictable[8 x 6]
_deleted |_id |_pk |age|name |surname
2021-03-07 21:04:34.355000|60453fe20e096da27d7d2150|['name', 'surname']|2 |alan |adams
2021-03-07 21:04:34.355000|60453fe20e096da27d7d2151|['name', 'surname']|3 |alan |jones
2021-03-07 21:04:34.355000|60453fe20e096da27d7d2152|['name', 'surname']|4 |barbara|brown
...8 rows...
2021-03-07 21:04:34.289000|60453fe20e096da27d7d2155|['name', 'surname']|2 |alan |jones
2021-03-07 21:04:34.293000|60453fe20e096da27d7d2156|['name', 'surname']|3 |barbara|brown
2021-03-07 21:04:34.298000|60453fe20e096da27d7d2157|['name', 'surname']|4 |chris |jones
mongo_reader and mongo_pk_reader¶
Because it is so easy to do stuff in MongoDB, we could easily cause damage to the date underlying. We therefore also introduced read-only versions for the mongo_cursor and pk_cursor:
[77]:
pkr = mongo_table(table = 'test', db = 'test', pk = ['name', 'surname'], mode = 'r')
pkr
[77]:
<class 'pyg.mongo._pk_reader.mongo_pk_reader'> for Collection(Database(MongoClient(host=['localhost:27017'], document_class=dict, tz_aware=False, connect=True), 'test'), 'test')
M{'$and': [{"_deleted": {"$exists": false}}, {"_pk": {"$eq": ["name", "surname"]}}]} None
documents count: 0
[78]:
with pytest.raises(AttributeError):
pkr.drop()
[80]:
r = mongo_table(table = 'test', db = 'test', mode = 'r')
with pytest.raises(AttributeError):
r.drop()
r[::]
[80]:
dictable[8 x 6]
_deleted |_id |_pk |age|name |surname
2021-03-07 21:04:34.355000|60453fe20e096da27d7d2150|['name', 'surname']|2 |alan |adams
2021-03-07 21:04:34.355000|60453fe20e096da27d7d2151|['name', 'surname']|3 |alan |jones
2021-03-07 21:04:34.355000|60453fe20e096da27d7d2152|['name', 'surname']|4 |barbara|brown
...8 rows...
2021-03-07 21:04:34.289000|60453fe20e096da27d7d2155|['name', 'surname']|2 |alan |jones
2021-03-07 21:04:34.293000|60453fe20e096da27d7d2156|['name', 'surname']|3 |barbara|brown
2021-03-07 21:04:34.298000|60453fe20e096da27d7d2157|['name', 'surname']|4 |chris |jones
[ ]: