Package: postgresql-orm
InstallationConfigurationPrepareClassModelAddGetSaveAllDeleteFilterCustomTableFieldsJoinsInstallationpip install psycopg2-binary
pip install postgresql-ormConfigurationImport module:importsqlSet log level to debug to see generated queriesimportloggingasloglog.basicConfig(level=log.DEBUG)Configure default database connetion:sql.db=sql.Db('dbname=postgres user=postgres password=1234 host=127.0.0.1 port=5432')You can also set default schema for a default model:sql.Table.schema='demo'That is enough for a setup, but below we describe advanced mechanics of database connection:Connetion is made before the very first query to database. By default 20 thread safe connection pool is reservedsql.Db('..', size=20). To establish connection manually callsql.db.init().sql.db.get()gets free database connection from the pull, after finishing a job the connection is returned back to the connection pull by callingsql.db.put(connection).Alternatively every model can have its own database connection and its own schema, it is achieved by extending sql.Table, the default model class:importsqlclassUsers(sql.Table):db=sql.Db('host=198.168.0.1 dbname=core')schema='site'name='users'fields={}classTransactions(sql.Table):db=sql.Db('host=198.168.0.2 dbname=reporting')schema='financial'name='transaction'fields={}PrepareLet us create a demo schema for a small tutorial:sql.query('DROP SCHEMA IF EXISTS demo CASCADE')sql.query('CREATE SCHEMA IF NOT EXISTS demo')We will have two tables: users and groups, users table will reference groups table to showcase some joinssql.query("""CREATE TABLE IF NOT EXISTS demo.groups (id SMALLSERIAL PRIMARY KEY NOT NULL,name VARCHAR(32))""")sql.query("""CREATE TABLE IF NOT EXISTS demo.users (id BIGSERIAL PRIMARY KEY NOT NULL,username VARCHAR(64) NOT NULL,fullname VARCHAR(64) NOT NULL,password CHAR(32) NOT NULL,status VARCHAR(8) NOT NULL,group_id SMALLINT REFERENCES demo.groups(id),created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW())""")ClassOnce we have tables we create classes for representing users and groups table rows as objects:classGroup:def__init__(self):self.id=Noneself.name=NoneclassUser:def__init__(self):self.id=Noneself.username=Noneself.fullname=Noneself.status=Noneself.group_id=Noneself.created_at=Noneself.group=NoneAlso defining properties is not required as orm creates object properties on the fly, but having them is much more descriptive.ModelWe extend sql.Table for our Groups model. The naming goes like this: Class name in singular [ Group ] and model name is in plural [ Groups] as Groups model produces Group class objects:classGroups(sql.Table):name='groups'type=Groupfields={'id':{'type':'int'},'name':{}}Wheretype = Groupattaches previously created class to a model.And Users model:classUsers(sql.Table):name='users'# Actual table nametype=Userfields={'id':{'type':'int'},'username':{},# Default is string'fullname':{},'password':{'encoder':md5},# md5 function will encode values for this field'status':{'options':['active','disabled']},# Only this values are allowed for this field'group_id':{'type':'int'},'created_at':{'type':'date'}}joins={'group':{'table':Groups,'field':'group_id'}}Let us pause a bit to create a friendly md5 hash function which we used in password encoder:importhashlibdefmd5(plain):returnhashlib.md5(plain.encode()).hexdigest()Storing passwords in md5 hashs is not recomended in real world scenario, you should use bcrypt instead.AddCreate some groups by simply calling Groups.add and passing dict type object, where keys of the object are Groups.fields dict keys:manager=Groups.add({'name':'Manager'})customer=Groups.add({'name':'Customer'})Method will generate and run following query:WITH"groups"AS(INSERTINTO"demo"."groups"(name)VALUES('Manager')RETURNINGgroups.id,groups.name)SELECTgroups.id,groups.nameFROM"groups"Newly created row is selected in the same query usingRETURNINGand converted into Group object, manager now holds{'id':1, 'name':'Manager'}Create usersuser=Users.add({'username':'john','fullname':'John Doe','password':'123','status':'active','group_id':manager.id})Following query will be generated:WITH"users"AS(INSERTINTO"demo"."users"(username,fullname,password,status,group_id)VALUES('john','John Doe','202cb962ac59075b964b07152d234b70','active','1')RETURNINGusers.id,users.username,users.fullname,users.password,users.status,users.group_id,users.created_at)SELECTusers.id,users.username,users.fullname,users.password,users.status,users.group_id,users.created_at,groups.id,groups.nameFROM"users"LEFTJOIN"demo"."groups"ON"groups"."id"="users"."group_id"Let us create a pretty print functionimportjsondefpprint(object):print(json.dumps(object,indent=4,default=lambdax:x.__dict__ifhasattr(x,'__dict__')elsestr(x)))Actually newly created user is an object of a class User, but pprint will visualise it like a dictionary:pprint(user)Outputs:{"id":1,"username":"john","fullname":"John Doe","status":"active","created_at":"2020-11-14 03:34:46.913425","password":"202cb962ac59075b964b07152d234b70","group_id":1,"group":{"id":1,"name":"Manager"}}Notice that password we input was plain'123'string and in query it is md5 hash thanks to encoder defined to that field'password':{'encoder': md5}.Here we add some more users for scientific purposes:importrandomrandom_string=lambda:''.join(random.choice('abcdefghijklmnopqrstwxyz')forjinrange(random.randrange(3,9)))log.getLogger().setLevel(log.INFO)foriinrange(300):Users.add({'username':random_string(),'fullname':random_string().capitalize()+' '+random_string().capitalize(),'group_id':random.choice((manager.id,customer.id)),'password':'123','status':'active'})log.getLogger().setLevel(log.DEBUG)Getuser=Users.get(1)Wich will get user by following query and because we defined a join on Groups model, query will contain LEFT JOIN on groups table:SELECTusers.id,users.username,users.fullname,users.password,users.status,users.group_id,users.created_at,groups.id,groups.nameFROM"demo"."users"LEFTJOIN"demo"."groups"ON"groups"."id"="users"."group_id"WHERE"users"."id"='1'AND1=1Let us look inside User objectpprint(user)Which outputs:{"id":1,"username":"john","fullname":"John Doe","status":"active","created_at":"2020-11-14 03:34:46.913425","password":"202cb962ac59075b964b07152d234b70","group_id":1,"group":{"id":1,"name":"Manager"}}If you look closer you see that even user.group is an object, actually it is an object of the Group class.SaveSaving happens via id and dict corresponding fields and values, save returns updated object of the user:user=Users.save(1,{'status':'disabled','password':'qwerty'})While savingi you pass only fields which you inted to update, at least one field is required.Generated query:WITH"users"AS(UPDATE"demo"."users"SETpassword='d8578edf8458ce06fbc5bb76a58c5ca4',status='disabled'WHERE"users"."id"='1'AND1=1RETURNINGusers.id,users.username,users.fullname,users.password,users.status,users.group_id,users.created_at)SELECTusers.id,users.username,users.fullname,users.password,users.status,users.group_id,users.created_at,groups.id,groups.nameFROM"users"LEFTJOIN"demo"."groups"ON"groups"."id"="users"."group_id"Everything happens in same query: update, select and also join on groups tableuser in case of success now contains actually updated object:pprint(user){"id":1,"username":"john","fullname":"John Doe","status":"disabled","created_at":"2020-11-14 03:34:46.913425","password":"d8578edf8458ce06fbc5bb76a58c5ca4","group_id":1,"group":{"id":1,"name":"Manager"}}Allusers=Users.all(filter={'id':{'from':1,#'to': 300},'status':'active','group':{'id':manager.id}},search={'username':'j','fullname':'j'},limit=2,order={'field':'username','method':'asc'})Query:SELECTusers.id,users.username,users.fullname,users.password,users.status,users.group_id,users.created_at,groups.id,groups.nameFROM"demo"."users"LEFTJOIN"demo"."groups"ON"groups"."id"="users"."group_id"WHERE(users."username"ILIKE'%j%'ORusers."fullname"ILIKE'%j%')AND(users."id">='5'ANDusers."status"='disabled'ANDgroups."id"='2')ORDERBYusers."id"DESCDifference between filter and search is that search consists with onlyORcriterias and filter withAND.pprint(users)[{"id":122,"username":"ahdcjq","fullname":"Hbf Ngggzmce","status":"active","created_at":"2020-11-14 04:48:00.857954","password":"202cb962ac59075b964b07152d234b70","group_id":1,"group":{"id":1,"name":"Manager"}},{"id":51,"username":"alabm","fullname":"Ipb Ttjkxc","status":"active","created_at":"2020-11-14 04:48:00.612368","password":"202cb962ac59075b964b07152d234b70","group_id":1,"group":{"id":1,"name":"Manager"}}]DeleteUsers.delete(3)DELETEFROM"demo"."users"WHERE1=1AND"users"."id"='3'FilterIn addition with Table.all, Table.filter has paging and result is object of sql.Result:result=Users.filter(page=4,limit=3,order={'method':'asc'},filter={'status':'active','group':{'id':customer.id}},search={'username':'j','fullname':'j'})SELECTusers.id,users.username,users.fullname,users.password,users.status,users.group_id,users.created_at,groups.id,groups.name,COUNT(*)OVER()FROM"demo"."users"LEFTJOIN"demo"."groups"ON"groups"."id"="users"."group_id"WHERE(users."username"ILIKE'%j%'ORusers."fullname"ILIKE'%j%')AND(users."status"='active'ANDgroups."id"='2')ORDERBYusers."id"ASCLIMIT'3'OFFSET'9'Selecting 3 rows starting from 9th row as we have per page limit=3 from 9-12 will be items for 4th pagepprint(result){"total":80,"items":[{"id":42,"username":"xkeosa","fullname":"Hxkqrfz Wxjhbalf","status":"active","created_at":"2020-11-14 04:50:37.132306","password":"202cb962ac59075b964b07152d234b70","group_id":2,"group":{"id":2,"name":"Customer"}},{"id":50,"username":"lejfe","fullname":"Npowa Sllgq","status":"active","created_at":"2020-11-14 04:50:37.156698","password":"202cb962ac59075b964b07152d234b70","group_id":2,"group":{"id":2,"name":"Customer"}},{"id":52,"username":"pofmeyp","fullname":"Xpm Zxfthj","status":"active","created_at":"2020-11-14 04:50:37.161491","password":"202cb962ac59075b964b07152d234b70","group_id":2,"group":{"id":2,"name":"Customer"}}]}CustomBut if you want to create some custom query Model class helps a lot with query templating and converting select result into objects of User:db=Nonetry:db=Users.db.get()cursor=db.cursor()cursor.execute(*sql.debug(f"""SELECT{Users.select()}FROM{Users}WHERE{Users('username')}=%sAND{Users('password')}=%s""",('John',md5('123'))))ifcursor.rowcount>0:# Create User objectuser=Users.create(cursor.fetchone())pprint(user)finally:Users.db.put(db)TableimportsqlclassProfile(sql.Table):# Override database connection default is sql.db = sql.Db('...')db=sql.Db(...)# Override default schema, default is None i.e. publicschema='site'# Actual table namename='user_profile'# Primary key of table, default is 'id'id='user_id'# Definition of table fieldsfields={}# Definition of table joinsjoins={}FieldsField types are: string(default), int, float, bool, date and jsonField type is specified by'type': 'int'Common field options are:importsqlTablefields(sql.Table)fields={'name':{'type':'int','array':True,'options':[1,2,3,4,5],# Only this values are accepted anything else causes exception'field':'actual_table_field_name',# default is same as field key'encoder':lambdax:x*2,# Encoder is called right before insert or update value after validation'decoder':lambdax:x/2,# Decoder is called after select,'select':True,# Default is True'insert':True,# Default is True'update':True,# Default is True'null':False,# Allow None values in inserts and updates and cast them into null}}If'null'isTruethenNonevalues are transfered asnull, by defaultNonevalue fields are ignored in inserts and updates as'null'isFalse.Json field requireskeyssetting:importsqlclassTable(sql.Table):fields={'title':{'type':'json','keys':['en','ka','ru']# This keys are used for ordering by json field}}# This is how table is ordered with json field keyTable.filter(order={'field':'title.en'})JoinsimportsqlclassTable1Class:def__init__(self):self.id=NoneclassTable2Class:def__init__(self):self.id=Noneself.table1_id=None# This just contains table1_idself.table1=None# This will contain Table1Class objectclassTable1Model(sql.Table):id='id'type=Table1Classfields={'id':{'type':'int'}}classTable2Model(sql.Table):type=Table2Classfields={'id':{'type':'int'}'table1_id':{'type':'int'}}joins:{'table1':{'table':Table1Model,'field':'table1_id'}}