Getting Started

Philosophy

The aiosql project is for writing SQL to interact with a database. Most database libraries are intended to reduce the amount of SQL developers need to write, aiosql takes an alternative approach. Why?

  • Alternatives are good. No approach fits all use cases, no matter how predominant.

  • SQL is the most expressive and performant way to interact with a SQL database.

  • Investigating where a query came from is simpler when it is source controlled, named, and written by a human.

  • Writing SQL in files gives you built-in compatibility with powerful SQL tools like DataGrip and psql.

About ORMs

ORMs and SQL Query Builders offer object interfaces to generate and execute SQL. They exist to ease development, not to make it simpler. They have a large code base to provide their services, and many complement packages to provide more advanced features, resulting in over a hundred of thousand lines of code. Inheriting object hierarchies, mixing data with behaviors, mirroring a database schema, and generating SQL are not simple. ORMs are introduced early in a project’s life when requirements are limited and the need to move fast is paramount. As a project grows, ORM objects and their relations grow too, they become a source of complexity and coupling.

aiosql doesn’t solve these problems directly either, your application will still get more complex with time. You can write bad SQL and bad python. But, with aiosql there is no mandate that all interaction with the database go through a complex network of related python objects that mirror a database schema. The only mandates are that you write SQL to talk to the database and python to use the data. From there you start with a system in which the database and the application are intentionally separate and independent from each other so they can change independently. The architecture of your application and the boundaries you choose between it and the database is left to you.

The documentation for projects like SQLAlchemy and Django DB can give you a better vision for the class of problems that ORMs do solve and the productivity gains they intend. Please choose these projects over aiosql if you find that they fit the needs of your application better.

Loading Queries

This section goes over the three ways to make SQL queries available for execution in python. You’ll learn the basics of defining queries so aiosql can find them and turn them into methods on a Queries object. For more details reference the Defining SQL Queries documentation.

From a SQL File

SQL can be loaded by providing a path to a .sql file. Below is a blogs.sql file that defines two queries.

-- name: get_all_blogs
select blogid,
       userid,
       title,
       content,
       published
  from blogs;

-- name: get_user_blogs
-- Get blogs with a fancy formatted published date and author field
    select b.blogid,
           b.title,
           strftime('%Y-%m-%d %H:%M', b.published) as published,
           u.username as author
      from blogs b
inner join users u on b.userid = u.userid
     where u.username = :username
  order by b.published desc;

Notice the -- name: <name_of_method> comments and the :username substitution variable. The comments that start with -- name: are the magic of aiosql. They are used by `aiosql.from_path <./api.md#aiosqlfrom_path>`__ to parse the file into separate methods accessible by the name. The aiosql.from_path function takes a path to a sql file or directory and the name of the database driver intended for use with the methods.

queries = aiosql.from_path("blogs.sql", "sqlite3")

In the case of blogs.sql we expect the following two methods to be available. The username parameter of get_user_blogs will substitute in for the :username variable in the SQL. Standard SELECT statements return a generator, i.e. something which can be iterated upon, for instance with a for loop. Just cast the result to list to get an actual list. The generator returns what the underlying driver returns, usually tuples.

def get_all_blogs(self) -> Generator[Any]:
    pass

def get_user_blogs(self, username: str) -> Generator[Any]:
    pass

From an SQL String

SQL can be loaded from a string as well. The result below is the same as the first example above that loads from a SQL file.

sql_str = """
-- name: get_all_blogs
select blogid,
       userid,
       title,
       content,
       published
  from blogs;

-- name: get_user_blogs
-- Get blogs with a fancy formatted published date and author field
    select b.blogid,
           b.title,
           strftime('%Y-%m-%d %H:%M', b.published) as published,
           u.username as author
      from blogs b
inner join users u on b.userid = u.userid
     where u.username = :username
  order by b.published desc;
"""

queries = aiosql.from_str(sql_str, "sqlite3")

The Queries object here will have two methods:

queries.get_all_blogs(conn)
queries.get_user_blogs(conn, username="johndoe")

From a Directory of SQL Files

Loading a directory of SQL files loads all of the queries defined in those files into a single object. The example/sql directory below contains three .sql files and can be loaded using aiosql.from_path ./api.md#aiosqlfrom_path.

example/sql
        ├── blogs.sql
        ├── create_schema.sql
        └── users.sql
queries = aiosql.from_path("example/sql", "sqlite3")

The resulting queries object will have a mixture of methods from all the files.

Subdirectories

Introducing subdirectories allows namespacing queries. This provides a way to further organize and group queries conceptually. For instance, you could define blog queries separate from user queries access them on distinct properties of the queries object.

Assume the blogs.sql and users.sql files both contain a -- name: get_all query.

example/sql/
        ├── blogs/
        │   └── blogs.sql
        ├── create_schema.sql
        └── users/
            └── users.sql
queries = aiosql.from_path("example/sql", "sqlite3")

The Queries object has two nested get_all methods accessible on attributes .blogs and .users. The attributes reflect the names of the subdirectories.

queries.blogs.get_all(conn)
queries.users.get_all(conn)

Calling Query Methods

Connections

The connection or conn is always the first argument to an aiosql method. The conn is an open connection to a database driver that your aiosql method can use for executing the sql it contains. Controlling connections outside of aiosql queries means you can call multiple queries and control them under one transaction, or otherwise set connection level properties that affect driver behavior.

Note

For more see: Advanced Topics.

In the examples throughout this page a conn object has been passed. Here is a more code complete example that shows the connection creation and call to aiosql.from_path ./api.md#aiosqlfrom_path that make a queries object.

>>> import sqlite3
>>> import aiosql
>>> conn = sqlite3.connect("./blogs.db")
>>> # Note the "sqlite3" driver_adapter argument is what tells
>>> # aiosql it should be expecting a sqlite3 connection object.
>>> queries = aiosql.from_path("./blogs.sql", "sqlite3")
>>> queries.get_all_blogs(conn)
[(1,
  1,
  'What I did Today',
  'I mowed the lawn, washed some clothes, and ate a burger.\n'
  '\n'
  'Until next time,\n'
  'Bob',
  '2017-07-28'),
 (2, 3, 'Testing', 'Is this thing on?\n', '2018-01-01'),
 (3,
  1,
  'How to make a pie.',
  '1. Make crust\n2. Fill\n3. Bake\n4. Eat\n',
  '2018-11-23')]

See the associated AnoDB Project for embedding both a connection pool and queries.

Passing Parameters

-- name: get_user_blogs
-- Get blogs with a fancy formatted published date and author field
    select b.blogid,
           b.title,
           strftime('%Y-%m-%d %H:%M', b.published) as published,
           u.username as author
      from blogs b
inner join users u on b.userid = u.userid
     where u.username = :username
  order by b.published desc;

aiosql allows parameterization of queries by parsing values like :username in the above query and having the resultant method expect an inbound argument to substitute for :username.

You can call the get_user_blogs function with plain arguments or keyword arguments with the name of the subsitution variable.

>>> import sqlite3
>>> import aiosql
>>> conn = sqlite3.connect("./blogs.db")
>>> queries = aiosql.from_path("./blogs.sql", "sqlite3")
>>>
>>> # Using keyword args
>>> queries.get_user_blogs(conn, username="bobsmith")
[(3, 'How to make a pie.', '2018-11-23 00:00', 'bobsmith'), (1, 'What I did Today', '2017-07-28 00:00', 'bobsmith')]
>>>
>>> # Using positional argument
>>> queries.get_user_blogs(conn, "janedoe")
[(2, 'Testing', '2018-01-01 00:00', 'janedoe')]

Warning

When passing positional arguments aiosql will apply them in the order that the substitutions appear in your SQL. This can be convenient and clear in some cases, but very confusing in others. You might want to choose to always name your arguments for clarity. Consider enforcing this behavior by passing kwargs_only=True when creating the queries.

It is also possible to access simple object attributes in a query, with the dot syntax:

-- name: add_user
insert into users(username, name)
  values (:u.username, :u.name);

Then simple pass your object as u:

# User is some class with attributes username and name
calvin = User("calvin", "Calvin")
queries.add_user(u=calvin)