Package: windyquery
windyquery - A non-blocking Python PostgreSQL query builderWindyquery is a non-blocking PostgreSQL query builder with Asyncio.Installation$ pip install windyqueryConnectionimportasynciofromwindyqueryimportDB# create DB connection for CRUD operatonsdb=DB()asyncio.get_event_loop().run_until_complete(db.connect('db_name',{'host':'localhost','port':'5432','database':'db_name','username':'db_user_name','password':'db_user_password'},default=True))asyncio.get_event_loop().run_until_complete(db.connect('other_db_name',{'host':'localhost','port':'5432','database':'other_db_name','username':'db_user_name','password':'db_user_password'},default=False))# switch connections between different databasesdb.connection('other_db_name')# the default connection can also be changed directlydb.default='other_db_name'# close DB connectionasyncio.get_event_loop().run_until_complete(db.stop())CRUD examplesA DB instance can be used to constuct a SQL. The instance is a coroutine object.
It can be scheduled to run by allasynciomechanisms.Build a SQL and execute itasyncdefmain(db):# SELECT id, name FROM usersusers=awaitdb.table('users').select('id','name')print(users[0]['name'])asyncio.run(main(db))SELECT# SELECT name AS username, address addr FROM usersawaitdb.table('users').select('name AS username','address addr')# SELECT * FROM users WHERE id = 1 AND name = 'Tom'awaitdb.table('users').select().where('id',1).where('name','Tom')# SELECT * FROM users WHERE id = 1 AND name = 'Tom'awaitdb.table('users').select().where('id','=',1).where('name','=','Tom')# SELECT * FROM users WHERE id = 1 AND name = 'Tom'awaitdb.table('users').select().where('id = ? AND name = ?',1,'Tom')# SELECT * FROM users WHERE id IN (1, 2)awaitdb.table('cards').select().where("id",[1,2])# SELECT * FROM users WHERE id IN (1, 2)awaitdb.table('cards').select().where("id",'IN',[1,2])# SELECT * FROM users WHERE id IN (1, 2)awaitdb.table('cards').select().where("id IN (?, ?)",1,2)# SELECT * FROM users ORDER BY id, name DESCawaitdb.table('users').select().order_by('id','name DESC')# SELECT * FROM users GROUP BY id, nameawaitdb.table('users').select().group_by('id','name')# SELECT * FROM users LIMIT 100 OFFSET 10awaitdb.table('users').select().limit(100).offset(10)# SELECT users.*, orders.total FROM users#   JOIN orders ON orders.user_id = users.idawaitdb.table('users').select('users.*','orders.total').\join('orders','orders.user_id','=','users.id')# SELECT users.*, orders.total FROM users#   JOIN orders ON orders.user_id = users.id AND orders.total > 100awaitdb.table('users').select('users.*','orders.total').\join('orders','orders.user_id = users.id AND orders.total > ?',100)INSERT# INSERT INTO users(id, name) VALUES#   (1, 'Tom'),#   (2, 'Jerry'),#   (3, DEFAULT)awaitdb.table('users').insert({'id':1,'name':'Tom'},{'id':2,'name':'Jerry'},{'id':3,'name':'DEFAULT'})# INSERT INTO users(id, name) VALUES#   (1, 'Tom'),#   (2, 'Jerry'),#   (3, DEFAULT)#   RETRUNING id, nameawaitdb.table('users').insert({'id':1,'name':'Tom'},{'id':2,'name':'Jerry'},{'id':3,'name':'DEFAULT'}).returning('id','name')# INSERT INTO users(id, name) VALUES#   (1, 'Tom'),#   (2, 'Jerry'),#   (3, DEFAULT)#   RETRUNING *awaitdb.table('users').insert({'id':1,'name':'Tom'},{'id':2,'name':'Jerry'},{'id':3,'name':'DEFAULT'}).returning()# INSERT INTO users (id, name) VALUES#   (1, 'Tom')#   ON CONFLICT (id) DO NOTHINGawaitdb.table('users').insert({'id':1,'name':'Tom'},).on_conflict('(id)','DO NOTHING')# INSERT INTO users As u (id, name) VALUES#   (1, 'Tom')#   ON CONFLICT ON CONSTRAINT users_pkey#   DO UPDATE SET name = EXCLUDED.name || ' (formerly ' || u.name || ')'awaitdb.table('users AS u').insert({'id':1,'name':'Tom'},).on_conflict('ON CONSTRAINT users_pkey',"DO UPDATE SET name = EXCLUDED.name || ' (formerly ' || u.name || ')'")UPDATE# UPDATE cards SET name = 'Tom' WHERE id = 9awaitdb.table('cards').where('id',9).update({'name':'Tom'})# UPDATE cards SET total = total + 1 WHERE id = 9awaitdb.table('cards').update('total = total + 1').where('id',9)# UPDATE users SET name = 'Tom' WHERE id = 9 RETRUNING *awaitdb.table('users').update({'name':'Tom'}).where('id','=',9).returning()# UPDATE users SET name = 'Tom' WHERE id = 9 RETRUNING id, nameawaitdb.table('users').update({'name':'Tom'}).where('id','=',9).returning('id','name')# UPDATE users SET name = orders.name#   FROM orders#   WHERE orders.user_id = users.idawaitdb.table('users').update('name = orders.name').\from_table('orders').\where('orders.user_id = users.id')# UPDATE users SET name = products.name, purchase = products.name, is_paid = TRUE#   FROM orders#   JOIN products ON orders.product_id = products.id#   WHERE orders.user_id = users.idawaitdb.table('users').update('name = product.name, purchase = products.name, is_paid = ?',True).\from_table('orders').\join('products','orders.product_id','=','products.id').\where('orders.user_id = users.id')DELETE# DELETE FROM users WHERE id = 1awaitdb.table('users').where('id',1).delete()# DELETE FROM users WHERE id = 1 RETURNING id, nameawaitdb.table('users').where('id',1).delete().returning('id','name')Migration examplesThe DB instance can also be used to migrate database schema.CREATE TABLE# CREATE TABLE users (#    id            serial PRIMARY KEY,#    group_id      integer references groups (id) ON DELETE CASCADE,#    created_at    timestamp not null DEFAULT NOW(),#    email         text not null unique,#    is_admin      boolean not null default false,#    address       jsonb,#    payday        integer not null,#    CONSTRAINT unique_email UNIQUE(group_id, email)#    check(payday > 0 and payday < 8)#)awaitdb.schema('TABLE users').create('id            serial PRIMARY KEY','group_id      integer references groups (id) ON DELETE CASCADE','created_at    timestamp not null DEFAULT NOW()','email         text not null unique','is_admin      boolean not null default false','address       jsonb','payday        integer not null','CONSTRAINT unique_email UNIQUE(group_id, email)','check(payday > 0 and payday < 8)',)# CREATE TABLE accounts LIKE usersawaitdb.schema('TABLE accounts').create('like users')# CREATE TABLE IF NOT EXISTS accounts LIKE usersawaitdb.schema('TABLE IF NOT EXISTS accounts').create('like users')Modify TABLE# ALTER TABLE users#   ALTER   id TYPE bigint,#   ALTER   name SET DEFAULT 'no_name',#   ALTER   COLUMN address DROP DEFAULT,#   ALTER   "user info" SET NOT NULL,#   ALTER   CONSTRAINT check(payday > 1 and payday < 6),#   ADD     UNIQUE(name, email) WITH (fillfactor=70),#   ADD     FOREIGN KEY (group_id) REFERENCES groups (id) ON DELETE SET NULL,#   DROP    CONSTRAINT IF EXISTS idx_email CASCADEawaitdb.schema('TABLE users').alter('alter  id TYPE bigint','alter  name SET DEFAULT\'no_name\'','alter  COLUMN address DROP DEFAULT','alter  "user info" SET NOT NULL','add    CONSTRAINT check(payday > 1 and payday < 6)','add    UNIQUE(name, email) WITH (fillfactor=70)','add    FOREIGN KEY (group_id) REFERENCES groups (id) ON DELETE SET NULL','drop   CONSTRAINT IF EXISTS idx_email CASCADE',)# ALTER TABLE users RENAME TO accountsawaitdb.schema('TABLE users').alter('RENAME TO accounts')# ALTER TABLE users RENAME email TO email_addressawaitdb.schema('TABLE users').alter('RENAME email TO email_address')# ALTER TABLE users RENAME CONSTRAINT idx_name TO index_nameawaitdb.schema('TABLE users').alter('RENAME CONSTRAINT idx_name TO index_name')# ALTER TABLE users ADD COLUMN address textawaitdb.schema('TABLE users').alter('ADD COLUMN address text')# ALTER TABLE users DROP addressawaitdb.schema('TABLE users').alter('DROP address')# CREATE INDEX idx_email ON users (name, email)awaitdb.schema('INDEX idx_email ON users').create('name','email')# CREATE UNIQUE INDEX unique_name ON users(name) WHERE soft_deleted = FALSEawaitdb.schema('UNIQUE INDEX unique_name ON users').create('name',).where('soft_deleted',False)# DROP INDEX idx_email CASCADEawaitdb.schema('INDEX idx_email').drop('CASCADE')# DROP TABLE usersawaitdb.schema('TABLE users').drop()RawTherawmethod can be used to execute any form of SQL. Usually therawmethod is used to execute complex hard-coded (versus dynamically built) queries. It's also very common to userawmethod to run migrations.The input torawmethod is not validated, so it is not safe from SQL injection.RAW for complex SQLawaitdb.raw('SELECT ROUND(AVG(group_id),1) AS avg_id, COUNT(1) AS total_users FROM users WHERE id in ($1, $2, $3)',4,5,6)awaitdb.raw("SELECT * FROM (VALUES (1, 'one'), (2, 'two'), (3, 'three')) AS t (num, letter)")awaitdb.raw("""INSERT INTO user (id, name)SELECT $1, $2 WHERE NOT EXISTS (SELECT id FROM users WHERE id = $1)""",1,'Tom')RAW for migrationawaitdb.raw("""CREATE TABLE users(id                       INT NOT NULL,created_at               DATE NOT NULL,first_name               VARCHAR(100) NOT NULL,last_name                VARCHAR(100) NOT NULL,birthday_mmddyyyy        CHAR(10) NOT NULL,)""")WITH Clause using VALUES ListsThe PostgresVALUESprovides a way to generate a "constant table" from a list of values. Together with theWITHclause, a small set of data can be loaded into the DB and queried like a table.SELECT using WITH VALUES# WITH "my_values" ("text_col", "bool_col", "num_col", "dict_col", "datetime_col", "null_col", "null_col2") AS#   (VALUES#     ('Tom', TRUE, 2, '{"id": 1}'::jsonb, '2021-07-20 10:00:00+00:00'::timestamptz, NULL, NULL)#   )# SELECT * FROM "my_values"result=awaitdb.with_values('my_values',{'text_col':'Tom','bool_col':True,'num_col':2,'dict_col':{'id':1},'datetime_col':datetime.now(),'null_col':'null','null_col2':None}).table('my_values').select()result[0]['text_col']# 'Tom'result[0]['bool_col']# Trueresult[0]['num_col']# 2result[0]['dict_col']# '{"id": 1}'result[0]['datetime_col']# datetime.datetime(2021, 7, 20, 10, 0, tzinfo=datetime.timezone.utc)result[0]['null_col']# Noneresult[0]['null_col2']# None# join other tables# WITH "workers" ("task_id", "name") AS#   (VALUES#     (1, 'Tom'),#     (2, 'Jerry')#   )# SELECT#   "workers"."name" AS "worker_name",#   "tasks"."name" AS "task_name"# FROM "workers"# JOIN "tasks" ON "workers"."task_id" = "tasks"."id"awaitdb.with_values('workers',{'task_id':1,'name':'Tom'},{'task_id':2,'name':'Jerry'}).table('workers').select('workers.name AS worker_name','tasks.name AS task_name').join('tasks','workers.task_id = tasks.id').order_by('tasks.id')# multiple WITH VALUES# WITH "workers1" ("task_id", "name") AS#   (VALUES#     (1, 'Tom'),#     (2, 'Jerry')#   ), "workers2" ("task_id", "name") AS#   (VALUES#     (1, 'Topsy'),#     (2, 'Nibbles')#   )# SELECT#   "workers1"."name" AS "primary_worker_name",#   "workers2"."name" AS "secondary_worker_name",#   "tasks"."name" AS "task_name"# FROM "tasks"# JOIN "workers1" ON "workers1"."task_id" = "tasks"."id"# JOIN "workers2" ON "workers2"."task_id" = "tasks"."id"awaitdb.with_values('workers1',{'task_id':1,'name':'Tom'},{'task_id':2,'name':'Jerry'}).with_values('workers2',{'task_id':1,'name':'Topsy'},{'task_id':2,'name':'Nibbles'}).table('tasks').select('workers1.name AS primary_worker_name','workers2.name AS secondary_worker_name','tasks.name AS task_name').join('workers1','workers1.task_id = tasks.id').\join('workers2','workers2.task_id = tasks.id')UPDATE using WITH VALUES# WITH "workers" ("task_id", "name") AS#   (VALUES#     (1, 'Tom'),#     (2, 'Jerry')#   )# UPDATE "tasks"# SET#   "name" = "tasks"."name" || ' (worked by ' || "workers"."name" || ')'# FROM "workers"# WHERE#   "workers"."task_id" = "tasks"."id"# RETURNING#   "workers"."name" AS "worker_name",#   "tasks"."name" AS "task_name"awaitdb.with_values('workers',{'task_id':1,'name':'Tom'},{'task_id':2,'name':'Jerry'}).table('tasks').update("name = tasks.name || ' (worked by ' || workers.name || ')'").\from_table('workers').\where('workers.task_id = tasks.id').\returning('workers.name AS worker_name','tasks.name AS task_name')RAW using WITH VALUES# WITH "workers" ("task_id", "name") AS#   (VALUES#     (1, 'Tom'),#     (2, 'Jerry')#   )# SELECT * FROM tasks WHERE EXISTS(#   SELECT 1 FROM workers#   JOIN task_results ON workers.task_id = task_results.task_id#   WHERE workers.task_id = tasks.id# )awaitdb.with_values('workers',{'task_id':1,'name':'Tom'},{'task_id':2,'name':'Jerry'}).raw("""SELECT * FROM tasks WHERE EXISTS(SELECT 1 FROM workersJOIN task_results ON workers.task_id = task_results.task_idWHERE workers.task_id = tasks.id)""")JSONB examplesMethods are created to support jsonb data type for some simple use cases.Create a table with jsonb data type# CREATE TABLE users (#    id     serial PRIMARY KEY,#    data   jsonb#)awaitdb.schema('TABLE users').create('id     serial PRIMARY KEY','data   jsonb',)Select jsonb field# SELECT data->name AS name, data->>name AS name_text FROM usersrows=awaitdb.table('users').select('data','data->name AS name','data->>name AS name_text')# rows[0]['data'] == '{"name":"Tom"}'# rows[0]['name'] == '"Tom"'# rows[0]['name_text'] == 'Tom'# SELECT data->name AS name FROM users WHERE data->>name LIKE 'Tom%'awaitdb.table('users').select('data->name AS name').where('data->>name','LIKE','Tom%')# SELECT data->name AS name FROM users WHERE data->name = '"Tom"'awaitdb.table('users').select('data->name AS name').where("data->name",'Tom')Insert jsonb field# INSERT INTO users (data) VALUES#   ('{"name": "Tom"}'),#   ('{"name": "Jerry"}')#   RETURNING *awaitdb.table('users').insert({'data':{'name':'Tom'}},{'data':{'name':'Jerry'}},).returning()Update jsonb field# UPDATE SET data = '{"address": {"city": "New York"}}'awaitdb.table('users').update({'data':{'address':{'city':'New York'}}})# UPDATE SET data = jsonb_set(data, '{address,city}', '"Chicago"')awaitdb.table('users').update({'data->address->city':'Chicago'})MigrationsWindyquery has a preliminary support for database migrations. The provided command-line script is calledwq.Generate a migration fileA migration file can be created by,# this creates a timestamped migration file, e.g. "20210705233408_create_my_table.py"$wqmake_migration--name=create_my_tableBy default, the new file is add todatabase/migrations/under the current working directory. If the diretory does not exist, it will be created first. The file contains an empty function to be filled by the user,asyncdefrun(db):# TODO: add code herepassSome sample migration templates are provided athere. They can be automatically inserted in the generated file by specifying the--templateparameter,# the generated file is pre-filled with some code template,# async def run(db):#     await db.schema('TABLE my_table').create(#         'id      serial PRIMARY KEY',#         'name    text not null unique',#     )$wqmake_migration--name=create_my_table--template="create table"# create a migration file that contains all avaiable templates$wqmake_migration--name=create_my_table--template=allRun migrationsTo run all of the outstanding migrations, use themigratesub-command,$wqmigrate--host=localhost--port=5432--database=my-db--username=my-name--password=my-pass# alternatively, the DB config can be provided by using environment variables$DB_HOST=localhostDB_PORT=5432DB_DATABASE=my-dbDB_USERNAME=my-nameDB_PASSWORD=my-passwqmigrateUse custom directory and database tableThewqcommand requires a directory to save the migration files, and a database table to store executed migrations. By default, the migration directory isdatabase/migrations/under the current working directroy, and the database table is namedmigrations. They are created automatically if they do not already exist.
The directory and table name can be customized by using--migration_dirand--migration_tableparameters,# creates the migrations file in "my_db_work/migrations/" of the current directory$wqmake_migration--name=create_my_table--migrations_dir="my_db_work/migrations"# looks for outstanding migrations in "my_db_work/migrations/" and stores finished migrations in my_migrations table in DB$wqmigrate--host=localhost--port=5432--database=my-db--username=my-name--password=my-pass--migrations_dir="my_db_work/migrations"--migrations_table=my_migrationsSyntax checkerA very important part of windyquery is to validate the inputs of the various builder methods. It defines aValidatorclass, which is used to reject input strings not following the proper syntax.
As a result, it can be used separately as a syntax checker for other DB libraries. For example, it is very common for REST API to support filtering or searching parameters specified by the users,......# GET /example-api/users?name=Tom&state=AZ;DROP%20TABLE%20Studentsurl_query="name=Tom&state=AZ;DROP TABLE Students"where=url_query.replace("&"," AND ")fromwindyquery.validatorimportValidatorfromwindyquery.validatorimportValidationErrorfromwindyquery.ctximportCtxtry:ctx=Ctx()validator=Validator()where=validator.validate_where(where,ctx)exceptValidationError:abort(400,f'Invalid query parameters:{url_query}')connection=psycopg2.connect(**dbConfig)cursor=connection.cursor()cursor.execute(f'SELECT * FROM users WHERE{where}')......Please note,Exceptraw, all windyquery's own builder methods, such asselect,update,where, and so on, already implicitly use these validation functions. They may be useful when used alone, for example, to help other DB libraries validate SQL snippets;These validation functions only cover a very small (though commonly used) subset of SQL grammar of Postgres.Listen for a notificationPostgres implementsLISTEN/NOTIFYfor interprocess communications.
In order to listen on a channel, use the DB.listen() method. It returns an awaitable object, which resolves to a dict when a notification fires.fromwindyquery.exceptionsimportListenConnectionClosed# method 1: manually call start() and stop()listener=db.listen('my_table')awaitlistener.start()try:for_inrange(100):result=awaitlistener# or result = await listener.next()print(result)# {#     'channel': 'my_table',#     'payload': 'payload fired by the notifier',#     'listener_pid': 7321,#     'notifier_pid': 7322# }exceptListenConnectionClosedase:print(e)finally:awaitlistener.stop()# method 2: use with statementasyncwithdb.listen('my_table')aslistener:for_inrange(100):result=awaitlistenerprint(result)RRULEWindyquery has a rrule function that can "expand" a rrule string into it occurrences (a list of datetimes) by usingdateutil. A values CTE is prepared from the rrule occurrences, which can be further used by other querries.A simple rrule examplerruleStr="""DTSTART:20210303T100000ZRRULE:FREQ=DAILY;COUNT=5"""# WITH my_rrules ("rrule") AS# (VALUES#   ('2021-03-03 10:00:00+00:00'::timestamptz),#   ('2021-03-04 10:00:00+00:00'::timestamptz),#   ('2021-03-05 10:00:00+00:00'::timestamptz),#   ('2021-03-06 10:00:00+00:00'::timestamptz),#   ('2021-03-07 10:00:00+00:00'::timestamptz)# )# SELECT * FROM my_rrulesawaitdb.rrule('my_rrules',{'rrule':rruleStr}).table('my_rrules').select()More than one rrulesrruleStr1="""DTSTART:20210303T100000ZRRULE:FREQ=DAILY;COUNT=5"""rruleStr2="""DTSTART:20210303T100000ZRRULE:FREQ=DAILY;INTERVAL=10;COUNT=3RRULE:FREQ=DAILY;INTERVAL=5;COUNT=3"""# WITH my_rrules ("rrule") AS# (VALUES#   ('2021-03-03 10:00:00+00:00'::timestamptz),#   ('2021-03-04 10:00:00+00:00'::timestamptz),#   ('2021-03-05 10:00:00+00:00'::timestamptz),#   ('2021-03-06 10:00:00+00:00'::timestamptz),#   ('2021-03-07 10:00:00+00:00'::timestamptz),#   ('2021-03-03 10:00:00+00:00'::timestamptz),#   ('2021-03-08 10:00:00+00:00'::timestamptz),#   ('2021-03-13 10:00:00+00:00'::timestamptz),#   ('2021-03-23 10:00:00+00:00'::timestamptz)# )# SELECT * FROM my_rrules)awaitdb.rrule('my_rrules',{'rrule':rruleStr1},{'rrule':rruleStr2}).table('my_rrules').select()# the rrule field can also take a list of mulitple rrules.# the previous example is equivalent toawaitdb.rrule('my_rrules',{'rrule':[rruleStr1,rruleStr2]}).table('my_rrules').select()Use exrulerruleStr="""DTSTART:20210303T100000ZRRULE:FREQ=DAILY;COUNT=5"""exruleStr="""DTSTART:20210303T100000ZRRULE:FREQ=DAILY;BYWEEKDAY=SA,SU"""# WITH my_rrules ("rrule") AS# (VALUES#   ('2021-03-03 10:00:00+00:00'::timestamptz),#   ('2021-03-04 10:00:00+00:00'::timestamptz),#   ('2021-03-05 10:00:00+00:00'::timestamptz)# )# SELECT * FROM my_rrulesawaitdb.rrule('my_rrules',{'rrule':rruleStr,'exrule':exruleStr}).table('my_rrules').select()Use rdate# WITH my_rrules ("rrule") AS# (VALUES#   ('2021-05-03 10:00:00+00:00'::timestamptz)# )# SELECT * FROM my_rrulesawaitdb.rrule('my_rrules',{'rdate':'20210503T100000Z'}).table('my_rrules').select()rruleStr="""DTSTART:20210303T100000ZRRULE:FREQ=DAILY;COUNT=5"""# WITH my_rrules ("rrule") AS# (VALUES#   ('2021-03-03 10:00:00+00:00'::timestamptz),#   ('2021-03-04 10:00:00+00:00'::timestamptz),#   ('2021-03-05 10:00:00+00:00'::timestamptz),#   ('2021-03-06 10:00:00+00:00'::timestamptz),#   ('2021-03-07 10:00:00+00:00'::timestamptz),#   ('2021-05-03 10:00:00+00:00'::timestamptz)# )# SELECT * FROM my_rrulesawaitdb.rrule('my_rrules',{'rrule':rruleStr,'rdate':'20210503T100000Z'}).table('my_rrules').select()# similary to rrule, the rdate field can take a list of date strings# WITH my_rrules ("rrule") AS# (VALUES#   ('2021-03-03 10:00:00+00:00'::timestamptz),#   ('2021-03-04 10:00:00+00:00'::timestamptz),#   ('2021-03-05 10:00:00+00:00'::timestamptz),#   ('2021-03-06 10:00:00+00:00'::timestamptz),#   ('2021-03-07 10:00:00+00:00'::timestamptz),#   ('2021-05-03 10:00:00+00:00'::timestamptz),#   ('2021-06-03 10:00:00+00:00'::timestamptz)# )# SELECT * FROM my_rrulesawaitdb.rrule('my_rrules',{'rrule':rruleStr,'rdate':['20210503T100000Z','20210603T100000Z']}).table('my_rrules').select()Use exdaterruleStr="""DTSTART:20210303T100000ZRRULE:FREQ=DAILY;COUNT=5"""# WITH my_rrules ("rrule") AS# (VALUES#   ('2021-03-03 10:00:00+00:00'::timestamptz),#   ('2021-03-05 10:00:00+00:00'::timestamptz),#   ('2021-03-06 10:00:00+00:00'::timestamptz),#   ('2021-03-07 10:00:00+00:00'::timestamptz)# )# SELECT * FROM my_rrulesawaitdb.rrule('my_rrules',{'rrule':rruleStr,'exdate':'20210304T100000Z'}).table('my_rrules').select()# similary to rrule, the exdate field can take a list of date strings# WITH my_rrules ("rrule") AS# (VALUES#   ('2021-03-03 10:00:00+00:00'::timestamptz),#   ('2021-03-05 10:00:00+00:00'::timestamptz),#   ('2021-03-07 10:00:00+00:00'::timestamptz)# )# SELECT * FROM my_rrulesawaitdb.rrule('my_rrules',{'rrule':rruleStr,'exdate':['20210304T100000Z','20210306T100000Z']}).table('my_rrules').select()Use after, before, and betweenrruleStr="""DTSTART:20210715T100000ZRRULE:FREQ=DAILY;COUNT=5"""# rrule_after returns the first recurrence after the given datetime dt.# WITH my_rrules ("rrule") AS# (VALUES#   ('2021-07-17 10:00:00+00:00'::timestamptz)# )# SELECT * FROM my_rrulesawaitdb.rrule('my_rrules',{'rrule':rruleStr,'rrule_after':{'dt':'20210716T100000Z'}}]}).table('my_rrules').select()# if the inc keyword is True dt is included if it is an occurrence.# WITH my_rrules ("rrule") AS# (VALUES#   ('2021-07-16 10:00:00+00:00'::timestamptz)# )# SELECT * FROM my_rrulesawaitdb.rrule('my_rrules',{'rrule':rruleStr,'rrule_after':{'dt':'20210716T100000Z','inc':True}}]}).table('my_rrules').select()# rrule_before returns the last recurrence before the given datetime dt.# WITH my_rrules ("rrule") AS# (VALUES#   ('2021-07-15 10:00:00+00:00'::timestamptz)# )# SELECT * FROM my_rrulesawaitdb.rrule('my_rrules',{'rrule':rruleStr,'rrule_before':{'dt':'20210716T100000Z'}}]}).table('my_rrules').select()# if the inc keyword is True dt is included if it is an occurrence.# WITH my_rrules ("rrule") AS# (VALUES#   ('2021-07-16 10:00:00+00:00'::timestamptz)# )# SELECT * FROM my_rrulesawaitdb.rrule('my_rrules',{'rrule':rruleStr,'rrule_before':{'dt':'20210716T100000Z','inc':True}}]}).table('my_rrules').select()# rrule_between returns all the occurrences of the rrule between after and before.# WITH my_rrules ("rrule") AS# (VALUES#   ('2021-07-17 10:00:00+00:00'::timestamptz)#   ('2021-07-18 10:00:00+00:00'::timestamptz)# )# SELECT * FROM my_rrulesawaitdb.rrule('my_rrules',{'rrule':rruleStr,'rrule_between':{'after':'20210716T100000Z','before':'20210719T100000Z'}}]}).table('my_rrules').select()# if the inc keyword is True after and/or before are included if they are occurrences.# WITH my_rrules ("rrule") AS# (VALUES#   ('2021-07-16 10:00:00+00:00'::timestamptz)#   ('2021-07-17 10:00:00+00:00'::timestamptz)#   ('2021-07-18 10:00:00+00:00'::timestamptz)#   ('2021-07-19 10:00:00+00:00'::timestamptz)# )# SELECT * FROM my_rrulesawaitdb.rrule('my_rrules',{'rrule':rruleStr,'rrule_between':{'after':'20210716T100000Z','before':'20210719T100000Z','inc':True}}]}).table('my_rrules').select()Join rrule with other tablesimportdatetimerruleStr1="""DTSTART:20210303T100000ZRRULE:FREQ=DAILY;COUNT=5"""rruleStr2="""DTSTART:20210303T100000ZRRULE:FREQ=DAILY;INTERVAL=10;COUNT=3RRULE:FREQ=DAILY;INTERVAL=5;COUNT=3"""# WITH task_rrules ("task_id", "rrule") AS# (VALUES#   (1, '2021-03-03 10:00:00+00:00'::timestamptz),#   (1, '2021-03-04 10:00:00+00:00'::timestamptz),#   (1, '2021-03-05 10:00:00+00:00'::timestamptz),#   (1, '2021-03-06 10:00:00+00:00'::timestamptz),#   (1, '2021-03-07 10:00:00+00:00'::timestamptz),#   (2, '2021-03-03 10:00:00+00:00'::timestamptz),#   (2, '2021-03-08 10:00:00+00:00'::timestamptz),#   (2, '2021-03-13 10:00:00+00:00'::timestamptz),#   (2, '2021-03-23 10:00:00+00:00'::timestamptz)# )# SELECT task_rrules.rrule, tasks.name# FROM task_rrules# JOIN tasks ON tasks.id = task_rrules.task_id# WHERE#   rrule > '2021-03-05 10:00:00+00:00' AND#   rrule < '2021-03-08 10:00:00+00:00'awaitdb.rrule('task_rrules',{'task_id':1,'rrule':rruleStr1},{'task_id':2,'rrule':rruleStr2}).table('task_rrules').join('tasks','tasks.id','=','task_rrules.task_id').where('rrule > ? AND rrule < ?',datetime.datetime(2021,3,5,10,0,tzinfo=datetime.timezone.utc),datetime.datetime(2021,3,8,10,0,tzinfo=datetime.timezone.utc),).select('task_rrules.rrule','tasks.name')Using rrule in updateimportdatetimerruleStr1="""DTSTART:20210303T100000ZRRULE:FREQ=DAILY;COUNT=5"""rruleStr2="""DTSTART:20210303T100000ZRRULE:FREQ=DAILY;INTERVAL=10;COUNT=3RRULE:FREQ=DAILY;INTERVAL=5;COUNT=3"""# WITH task_rrules ("task_id", "rrule") AS# (VALUES#   (1, '2021-03-03 10:00:00+00:00'::timestamptz),#   (1, '2021-03-04 10:00:00+00:00'::timestamptz),#   (1, '2021-03-05 10:00:00+00:00'::timestamptz),#   (1, '2021-03-06 10:00:00+00:00'::timestamptz),#   (1, '2021-03-07 10:00:00+00:00'::timestamptz),#   (2, '2021-03-03 10:00:00+00:00'::timestamptz),#   (2, '2021-03-08 10:00:00+00:00'::timestamptz),#   (2, '2021-03-13 10:00:00+00:00'::timestamptz),#   (2, '2021-03-23 10:00:00+00:00'::timestamptz)# )# UPDATE tasks SET result = 'done'# FROM task_rrules# WHERE task_rrules.task_id = tasks.idawaitdb.rrule('task_rrules',{'task_id':1,'rrule':rruleStr1},{'task_id':2,'rrule':rruleStr2}).table('tasks').update("result = 'done'").from_table('task_rrules').where('task_rrules.task_id = tasks.id')Using rrule with raw methodimportdatetimerruleStr1="""DTSTART:20210303T100000ZRRULE:FREQ=DAILY;COUNT=5"""rruleStr2="""DTSTART:20210303T100000ZRRULE:FREQ=DAILY;INTERVAL=10;COUNT=3RRULE:FREQ=DAILY;INTERVAL=5;COUNT=3"""# WITH task_rrules ("task_id", "rrule") AS# (VALUES#   (1, '2021-03-03 10:00:00+00:00'::timestamptz),#   (1, '2021-03-04 10:00:00+00:00'::timestamptz),#   (1, '2021-03-05 10:00:00+00:00'::timestamptz),#   (1, '2021-03-06 10:00:00+00:00'::timestamptz),#   (1, '2021-03-07 10:00:00+00:00'::timestamptz),#   (2, '2021-03-03 10:00:00+00:00'::timestamptz),#   (2, '2021-03-08 10:00:00+00:00'::timestamptz),#   (2, '2021-03-13 10:00:00+00:00'::timestamptz),#   (2, '2021-03-23 10:00:00+00:00'::timestamptz)# )# DELETE FROM tasks# WHERE EXISTS(#   SELECT 1 FROM task_rrules#   WHERE#     task_id = tasks.id AND#     rrule > '2021-03-20 10:00:00+00:00'# )# RETURNING id, task_idawaitdb.rrule('task_rrules',{'task_id':1,'rrule':rruleStr1},{'task_id':3,'rrule':rruleStr2}).raw("""DELETE FROM tasksWHERE EXISTS(SELECT 1 FROM task_rrulesWHEREtask_id = tasks.id ANDrrule > $1)RETURNING id, task_id""",datetime.datetime(2021,3,20,10,0,tzinfo=datetime.timezone.utc))Using a slice to limit the occurrencesimportdatetimerruleStr="""DTSTART:20210303T100000ZRRULE:FREQ=DAILY"""# WITH my_rrules ("task_id", "rrule") AS# (VALUES#   (1, '2021-03-03 10:00:00+00:00'::timestamptz),#   (1, '2021-03-04 10:00:00+00:00'::timestamptz),#   (1, '2021-03-05 10:00:00+00:00'::timestamptz),# )# SELECT * FROM my_rrulesawaitdb.rrule('my_rrules',{'rrule':rruleStr,'rrule_slice':slice(3)}).table('my_rrules').select()# WITH my_rrules ("task_id", "rrule") AS# (VALUES#   (1, '2021-03-13 10:00:00+00:00'::timestamptz),#   (1, '2021-03-15 10:00:00+00:00'::timestamptz),#   (1, '2021-03-17 10:00:00+00:00'::timestamptz),#   (1, '2021-03-19 10:00:00+00:00'::timestamptz),#   (1, '2021-03-21 10:00:00+00:00'::timestamptz),# )# SELECT * FROM my_rrulesawaitdb.rrule('my_rrules',{'rrule':rruleStr,'rrule_slice':slice(10,20,2)}).table('my_rrules').select()TestsWindyquery includestests. These tests are also served as examples on how to use this library.Running testsInstall pytest to run the included tests,pipinstall-UpytestSet up a postgres server with preloaded data. This can be done by usingdockerwith theofficial postgre docker image,dockerrun--rm--namewindyquery-test-p5432:5432-v${PWD}/windyquery/tests/seed_test_data.sql:/docker-entrypoint-initdb.d/seed_test_data.sql-ePOSTGRES_USER=windyquery-test-ePOSTGRES_PASSWORD=windyquery-test-ePOSTGRES_DB=windyquery-test-dpostgres:12-alpineNote: to use existing postgres server, it must be configured to have the correct user, password, and database needed in tests/conftest.py. Data needed by tests is in tests/seed_test_data.sql.To run the tests,pytest