Skip to content

Commit 6d8dcc0

Browse files
committed
Change simple API to deconflict with DB-API 2.0
Fixes #1
1 parent bdda4c6 commit 6d8dcc0

File tree

2 files changed

+150
-64
lines changed

2 files changed

+150
-64
lines changed

postgres.py

Lines changed: 116 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
Installation
55
------------
66
7-
:py:mod:`postgres` is available on `GitHub`_ and `PyPI`_::
7+
:py:mod:`postgres` is available on `GitHub`_ and on `PyPI`_::
88
99
$ pip install postgres
1010
@@ -15,25 +15,43 @@
1515
Instantiate a :py:class:`Postgres` object when your application starts:
1616
1717
>>> from postgres import Postgres
18-
>>> db = Postgres("postgres://jdoe@localhost/testdb")
18+
>>> db = Postgres("postgres://jrandom@localhost/testdb")
1919
20-
Use it to run SQL statements:
20+
Use :py:meth:`~postgres.Postgres.run` to run SQL statements:
2121
22-
>>> db.execute("CREATE TABLE foo (bar text)")
23-
>>> db.execute("INSERT INTO foo VALUES ('baz')")
24-
>>> db.execute("INSERT INTO foo VALUES ('buz')")
22+
>>> db.run("CREATE TABLE foo (bar text)")
23+
>>> db.run("INSERT INTO foo VALUES ('baz')")
24+
>>> db.run("INSERT INTO foo VALUES ('buz')")
2525
26-
Use it to fetch all results:
26+
Use :py:meth:`~postgres.Postgres.one` to fetch one result:
2727
28-
>>> db.fetchall("SELECT * FROM foo ORDER BY bar")
29-
[{"bar": "baz"}, {"bar": "buz"}]
28+
>>> db.one("SELECT * FROM foo ORDER BY bar")
29+
{'bar': 'baz'}
30+
>>> db.one("SELECT * FROM foo WHERE bar='blam'")
31+
None
3032
31-
Use it to fetch one result:
33+
Use :py:meth:`~postgres.Postgres.rows` to fetch all results:
3234
33-
>>> db.fetchone("SELECT * FROM foo ORDER BY bar")
34-
{"bar": "baz"}
35-
>>> db.fetchone("SELECT * FROM foo WHERE bar='blam'")
36-
None
35+
>>> db.rows("SELECT * FROM foo ORDER BY bar")
36+
[{'bar': 'baz'}, {'bar': 'buz'}]
37+
38+
39+
Bind Parameters
40+
+++++++++++++++
41+
42+
In case you're not familiar with bind parameters in DB-API 2.0, the basic idea
43+
is that you put ``%(foo)s`` in your SQL strings, and then pass in a second
44+
argument, a :py:class:`dict`, containing parameters that :py:mod:`psycopg2` (as
45+
an implementation of DB-API 2.0) will bind to the query in a way that is safe
46+
against SQL injection. (This is inspired by old-style Python string formatting,
47+
but it is not the same.)
48+
49+
>>> db.one("SELECT * FROM foo WHERE bar=%(bar)s", {"bar": "baz"})
50+
{'bar': 'baz'}
51+
52+
Never build SQL strings out of user input!
53+
54+
Always pass user input as bind parameters!
3755
3856
3957
Context Managers
@@ -53,7 +71,8 @@
5371
>>> with db.get_cursor() as cursor:
5472
... cursor.execute("SELECT * FROM foo ORDER BY bar")
5573
... cursor.fetchall()
56-
[{"bar": "baz"}, {"bar": "buz"}]
74+
...
75+
[{'bar': 'baz'}, {'bar': 'buz'}]
5776
5877
A cursor you get from :py:func:`~postgres.Postgres.get_cursor` has
5978
:py:attr:`autocommit` turned on for its connection, so every call you make
@@ -65,13 +84,19 @@
6584
... txn.execute("INSERT INTO foo VALUES ('blam')")
6685
... txn.execute("SELECT * FROM foo ORDER BY bar")
6786
... txn.fetchall()
68-
[{"bar": "baz"}, {"bar": "blam"}, {"bar": "buz"}]
6987
...
70-
... db.fetchall("SELECT * FROM foo ORDER BY bar")
71-
[{"bar": "baz"}, {"bar": "buz"}]
88+
[{'bar': 'baz'}, {'bar': 'blam'}, {'bar': 'buz'}]
89+
90+
Note that other calls won't see the changes on your transaction until the end
91+
of your code block, when the context manager commits the transaction::
92+
93+
>>> with db.get_transaction() as txn:
94+
... txn.execute("INSERT INTO foo VALUES ('blam')")
95+
... db.rows("SELECT * FROM foo ORDER BY bar")
7296
...
73-
... db.fetchall("SELECT * FROM foo ORDER BY bar")
74-
[{"bar": "baz"}, {"bar": "blam"}, {"bar": "buz"}]
97+
[{'bar': 'baz'}, {'bar': 'buz'}]
98+
>>> db.rows("SELECT * FROM foo ORDER BY bar")
99+
[{'bar': 'baz'}, {'bar': 'blam'}, {'bar': 'buz'}]
75100
76101
The :py:func:`~postgres.Postgres.get_transaction` manager gives you a cursor
77102
with :py:attr:`autocommit` turned off on its connection. If the block under
@@ -85,7 +110,8 @@
85110
... cursor = connection.cursor()
86111
... cursor.execute("SELECT * FROM foo ORDER BY bar")
87112
... cursor.fetchall()
88-
[{"bar": "baz"}, {"bar": "buz"}]
113+
...
114+
[{'bar': 'baz'}, {'bar': 'buz'}]
89115
90116
A connection gotten in this way will have :py:attr:`autocommit` turned off, and
91117
it'll never be implicitly committed otherwise. It'll actually be rolled back
@@ -155,18 +181,35 @@ class Postgres(object):
155181
:param int maxconn: The minimum size of the connection pool
156182
157183
This is the main object that :py:mod:`postgres` provides, and you should
158-
have one instance per process for each database your process needs to talk
159-
to. When instantiated, this object creates a `thread-safe connection pool
184+
have one instance per process for each PostgreSQL database your process
185+
wants to talk to using this library. When instantiated, this object creates
186+
a `thread-safe connection pool
160187
<http://initd.org/psycopg/docs/pool.html#psycopg2.pool.ThreadedConnectionPool>`_,
161188
which opens :py:attr:`minconn` connections immediately, and up to
162189
:py:attr:`maxconn` according to demand. The fundamental value of a
163190
:py:class:`~postgres.Postgres` instance is that it runs everything through
164191
its connection pool.
165192
193+
The names in our simple API, :py:meth:`~postgres.Postgres.run`,
194+
:py:meth:`~postgres.Postgres.one`, and :py:meth:`~postgres.Postgres.rows`,
195+
were chosen to be short and memorable, and to not conflict with the DB-API
196+
2.0 :py:meth:`execute`, :py:meth:`fetchone`, and :py:meth:`fetchall`
197+
methods, which have slightly different semantics (under DB-API 2.0 you call
198+
:py:meth:`execute` on a cursor and then call one of the :py:meth:`fetch*`
199+
methods on the same cursor to retrieve rows; with our simple API there is
200+
no second :py:meth:`fetch` step). The context managers on this class are
201+
named starting with :py:meth:`get_` to set them apart from the simple-case
202+
API. Note that when working inside a block under one of the context
203+
managers, you're using DB-API 2.0 (:py:meth:`execute` + :py:meth:`fetch*`),
204+
not our simple API (:py:meth:`~postgres.Postgres.run` /
205+
:py:meth:`~postgres.Postgres.one` / :py:meth:`~postgres.Postgres.rows`).
206+
166207
Features:
167208
168209
- Get back unicode instead of bytestrings.
169210
211+
>>> import postgres
212+
>>> db = postgres.Postgres("postgres://jrandom@localhost/test")
170213
171214
"""
172215

@@ -179,39 +222,53 @@ def __init__(self, url, minconn=1, maxconn=10):
179222
, connection_factory=Connection
180223
)
181224

182-
def execute(self, sql, parameters=None):
183-
"""Execute the query and discard any results.
225+
def run(self, sql, parameters=None):
226+
"""Execute a query and discard any results.
184227
185228
:param unicode sql: the SQL statement to execute
186229
:param parameters: the bind parameters for the SQL statement
187-
:type parameters: tuple or dict
230+
:type parameters: dict or tuple
188231
:returns: :py:const:`None`
189232
233+
>>> db.run("CREATE TABLE foo (bar text)")
234+
>>> db.run("INSERT INTO foo VALUES ('baz')")
235+
>>> db.run("INSERT INTO foo VALUES ('buz')")
236+
190237
"""
191238
with self.get_cursor() as cursor:
192239
cursor.execute(sql, parameters)
193240

194-
def fetchone(self, sql, parameters=None):
195-
"""Execute the query and return a single result.
241+
def one(self, sql, parameters=None):
242+
"""Execute a query and return a single result.
196243
197244
:param unicode sql: the SQL statement to execute
198245
:param parameters: the bind parameters for the SQL statement
199-
:type parameters: tuple or dict
246+
:type parameters: dict or tuple
200247
:returns: :py:class:`dict` or :py:const:`None`
201248
249+
>>> row = db.one("SELECT * FROM foo WHERE bar='baz'"):
250+
>>> print(row["bar"])
251+
baz
252+
202253
"""
203254
with self.get_cursor() as cursor:
204255
cursor.execute(sql, parameters)
205256
return cursor.fetchone()
206257

207-
def fetchall(self, sql, parameters=None):
208-
"""Execute the query and return all results.
258+
def rows(self, sql, parameters=None):
259+
"""Execute a query and return all resulting rows.
209260
210261
:param unicode sql: the SQL statement to execute
211262
:param parameters: the bind parameters for the SQL statement
212-
:type parameters: tuple or dict
263+
:type parameters: dict or tuple
213264
:returns: :py:class:`list` of :py:class:`dict`
214265
266+
>>> for row in db.rows("SELECT bar FROM foo"):
267+
... print(row["bar"])
268+
...
269+
baz
270+
buz
271+
215272
"""
216273
with self.get_cursor() as cursor:
217274
cursor.execute(sql, parameters)
@@ -223,10 +280,15 @@ def get_cursor(self, *a, **kw):
223280
224281
This is what :py:meth:`~postgres.Postgres.execute`,
225282
:py:meth:`~postgres.Postgres.fetchone`, and
226-
:py:meth:`~postgres.Postgres.fetchall` use under the hood. It's
227-
probably less directly useful than
228-
:py:meth:`~postgres.Postgres.get_transaction` and
229-
:py:meth:`~postgres.Postgres.get_connection`.
283+
:py:meth:`~postgres.Postgres.fetchall` use under the hood. You might
284+
use it if you want to access `cursor attributes
285+
<http://initd.org/psycopg/docs/cursor.html>`_, for example.
286+
287+
>>> with db.get_cursor() as cursor:
288+
... cursor.execute("SELECT * FROM foo")
289+
... cursor.rowcount
290+
...
291+
2
230292
231293
"""
232294
return CursorContextManager(self.pool, *a, **kw)
@@ -237,7 +299,15 @@ def get_transaction(self, *a, **kw):
237299
238300
Use this when you want a series of statements to be part of one
239301
transaction, but you don't need fine-grained control over the
240-
transaction.
302+
transaction. If your code block inside the :py:obj:`with` statement
303+
raises an exception, the transaction will be rolled back. Otherwise,
304+
it'll be committed.
305+
306+
>>> with db.get_transaction() as txn:
307+
... txn.execute("SELECT * FROM foo")
308+
... txn.fetchall()
309+
...
310+
[{'bar': 'baz'}, {'bar': 'buz'}]
241311
242312
"""
243313
return TransactionContextManager(self.pool, *a, **kw)
@@ -250,15 +320,22 @@ def get_connection(self):
250320
otherwise need full control, for example, to do complex things with
251321
transactions.
252322
323+
>>> with db.get_connection() as connection:
324+
... cursor = connection.cursor()
325+
... cursor.execute("SELECT * FROM foo")
326+
... cursor.fetchall()
327+
...
328+
[{'bar': 'baz'}, {'bar': 'buz'}]
329+
253330
"""
254331
return ConnectionContextManager(self.pool)
255332

256333

257334
class Connection(psycopg2.extensions.connection):
258335
"""This is a subclass of :py:class:`psycopg2.extensions.connection`.
259336
260-
This class is used as the :py:attr:`connection_factory` for the connection
261-
pool in :py:class:`Postgres`. Here are the differences from the base class:
337+
:py:class:`Postgres` uses this class as the :py:attr:`connection_factory`
338+
for its connection pool. Here are the differences from the base class:
262339
263340
- We set :py:attr:`autocommit` to :py:const:`True`.
264341
- We set the client encoding to ``UTF-8``.

tests.py

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,51 +13,60 @@ class WithSchema(TestCase):
1313

1414
def setUp(self):
1515
self.db = Postgres(DATABASE_URL)
16-
self.db.execute("DROP SCHEMA IF EXISTS public CASCADE")
17-
self.db.execute("CREATE SCHEMA public")
16+
self.db.run("DROP SCHEMA IF EXISTS public CASCADE")
17+
self.db.run("CREATE SCHEMA public")
1818

1919
def tearDown(self):
20-
self.db.execute("DROP SCHEMA IF EXISTS public CASCADE")
20+
self.db.run("DROP SCHEMA IF EXISTS public CASCADE")
2121

2222

2323
class WithData(WithSchema):
2424

2525
def setUp(self):
2626
WithSchema.setUp(self)
27-
self.db.execute("CREATE TABLE foo (bar text)")
28-
self.db.execute("INSERT INTO foo VALUES ('baz')")
29-
self.db.execute("INSERT INTO foo VALUES ('buz')")
27+
self.db.run("CREATE TABLE foo (bar text)")
28+
self.db.run("INSERT INTO foo VALUES ('baz')")
29+
self.db.run("INSERT INTO foo VALUES ('buz')")
3030

3131

32-
class TestExecute(WithSchema):
32+
class TestRun(WithSchema):
3333

34-
def test_execute_executes(self):
35-
self.db.execute("CREATE TABLE foo (bar text)")
36-
actual = self.db.fetchall("SELECT tablename FROM pg_tables "
37-
"WHERE schemaname='public'")
34+
def test_run_runs(self):
35+
self.db.run("CREATE TABLE foo (bar text)")
36+
actual = self.db.rows("SELECT tablename FROM pg_tables "
37+
"WHERE schemaname='public'")
3838
assert actual == [{"tablename": "foo"}]
3939

40-
def test_execute_inserts(self):
41-
self.db.execute("CREATE TABLE foo (bar text)")
42-
self.db.execute("INSERT INTO foo VALUES ('baz')")
43-
actual = len(self.db.fetchone("SELECT * FROM foo ORDER BY bar"))
40+
def test_run_inserts(self):
41+
self.db.run("CREATE TABLE foo (bar text)")
42+
self.db.run("INSERT INTO foo VALUES ('baz')")
43+
actual = len(self.db.one("SELECT * FROM foo ORDER BY bar"))
4444
assert actual == 1
4545

4646

47-
class TestFetch(WithData):
47+
class TestOneAndRows(WithData):
4848

49-
def test_fetchone_fetches_the_first_one(self):
50-
actual = self.db.fetchone("SELECT * FROM foo ORDER BY bar")
49+
def test_one_fetches_the_first_one(self):
50+
actual = self.db.one("SELECT * FROM foo ORDER BY bar")
5151
assert actual == {"bar": "baz"}
5252

53-
def test_fetchone_returns_None_if_theres_none(self):
54-
actual = self.db.fetchone("SELECT * FROM foo WHERE bar='blam'")
53+
def test_one_returns_None_if_theres_none(self):
54+
actual = self.db.one("SELECT * FROM foo WHERE bar='blam'")
5555
assert actual is None
5656

57-
def test_fetchall_fetches_all(self):
58-
actual = self.db.fetchall("SELECT * FROM foo ORDER BY bar")
57+
def test_rows_fetches_all_rows(self):
58+
actual = self.db.rows("SELECT * FROM foo ORDER BY bar")
5959
assert actual == [{"bar": "baz"}, {"bar": "buz"}]
6060

61+
def test_bind_parameters_as_dict_work(self):
62+
params = {"bar": "baz"}
63+
actual = self.db.rows("SELECT * FROM foo WHERE bar=%(bar)s", params)
64+
assert actual == [{"bar": "baz"}]
65+
66+
def test_bind_parameters_as_tuple_work(self):
67+
actual = self.db.rows("SELECT * FROM foo WHERE bar=%s", ("baz",))
68+
assert actual == [{"bar": "baz"}]
69+
6170

6271
class TestCursor(WithData):
6372

@@ -93,14 +102,14 @@ def test_transaction_is_isolated(self):
93102
with self.db.get_transaction() as txn:
94103
txn.execute("INSERT INTO foo VALUES ('blam')")
95104
txn.execute("SELECT * FROM foo ORDER BY bar")
96-
actual = self.db.fetchall("SELECT * FROM foo ORDER BY bar")
105+
actual = self.db.rows("SELECT * FROM foo ORDER BY bar")
97106
assert actual == [{"bar": "baz"}, {"bar": "buz"}]
98107

99108
def test_transaction_commits_on_success(self):
100109
with self.db.get_transaction() as txn:
101110
txn.execute("INSERT INTO foo VALUES ('blam')")
102111
txn.execute("SELECT * FROM foo ORDER BY bar")
103-
actual = self.db.fetchall("SELECT * FROM foo ORDER BY bar")
112+
actual = self.db.rows("SELECT * FROM foo ORDER BY bar")
104113
assert actual == [{"bar": "baz"}, {"bar": "blam"}, {"bar": "buz"}]
105114

106115
def test_transaction_rolls_back_on_failure(self):
@@ -112,7 +121,7 @@ class Heck(Exception): pass
112121
raise Heck
113122
except Heck:
114123
pass
115-
actual = self.db.fetchall("SELECT * FROM foo ORDER BY bar")
124+
actual = self.db.rows("SELECT * FROM foo ORDER BY bar")
116125
assert actual == [{"bar": "baz"}, {"bar": "buz"}]
117126

118127

0 commit comments

Comments
 (0)