Skip to content

Commit 0cb5835

Browse files
Merge pull request #7 from NYPL/update-db-clients
Update db clients
2 parents 24d1e1e + d9722f8 commit 0cb5835

8 files changed

Lines changed: 198 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
# Changelog
22

3-
## v0.0.4 - 2/13
3+
## v0.0.5 - 2/22/23
4+
- Support write queries to PostgreSQL and MySQL databases
5+
- Support different return formats when querying PostgreSQL, MySQL, and Redshift databases
6+
7+
## v0.0.4 - 2/13/23
48
- In PostgreSQLClient, allow reconnecting after `close_connection` has been called
59
- Updated README with deployment information
610

7-
## v0.0.3 - 2/10
11+
## v0.0.3 - 2/10/23
812

913
- Added GitHub Actions workflow for deploying to production
1014
- Switched PostgreSQLClient to use connection pooling
1115

12-
## v0.0.2 - 2/6
16+
## v0.0.2 - 2/6/23
1317

1418
- Added CODEOWNERS
1519
- Added GitHub Actions workflows for running tests and deploying to QA
1620
- Added tests for helper functions
1721
- Updated Avro encoder to avoid dependency on pandas
1822

19-
## v0.0.1 - 1/26
23+
## v0.0.1 - 1/26/23
2024

2125
Initial version. Includes the `avro_encoder`, `kinesis_client`, `mysql_client`, `postgresql_client`, `redshift_client`, and `s3_client` classes as well as the `config_helper`, `kms_helper`, `log_helper`, and `obfuscation_helper` functions.

pyproject.toml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "nypl_py_utils"
7-
version = "0.0.5"
7+
version = "0.0.6"
88
authors = [
99
{ name="Aaron Friedman", email="aaronfriedman@nypl.org" },
1010
]
@@ -22,8 +22,7 @@ dependencies = [
2222
"boto3>=1.26.5",
2323
"botocore>=1.29.5",
2424
"mysql-connector-python>=8.0.32",
25-
"psycopg>=3.1.0",
26-
"psycopg-pool>=3.1.6",
25+
"psycopg[binary,pool]>=3.1.6",
2726
"PyYAML>=6.0",
2827
"redshift-connector>=2.0.909",
2928
"requests>=2.28.1"

src/nypl_py_utils/classes/mysql_client.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,41 @@ def connect(self):
3333
'Error connecting to {name} database: {error}'.format(
3434
name=self.database, error=e)) from None
3535

36-
def execute_query(self, query):
36+
def execute_query(self, query, is_write_query=False, query_params=None,
37+
dictionary=False):
3738
"""
3839
Executes an arbitrary query against the given database connection.
3940
40-
Returns a sequence of tuples representing the rows returned by the
41-
query.
41+
Parameters
42+
----------
43+
query: str
44+
The query to execute
45+
is_write_query: bool, optional
46+
Whether or not the query is writing to the database, in which case
47+
the transaction needs to be committed and None should be returned
48+
query_params: sequence, optional
49+
The values to be used in a parameterized query
50+
dictionary: bool, optional
51+
Whether the data will be returned as a dictionary. Defaults to
52+
False, which means the data is returned as a list of tuples.
53+
54+
Returns
55+
-------
56+
None or sequence
57+
None if is_write_query is True. A list of either tuples or
58+
dictionaries (based on the dictionary input) if is_write_query is
59+
False.
4260
"""
4361
self.logger.info('Querying {} database'.format(self.database))
4462
self.logger.debug('Executing query {}'.format(query))
4563
try:
46-
cursor = self.conn.cursor()
47-
cursor.execute(query)
48-
return cursor.fetchall()
64+
cursor = self.conn.cursor(dictionary=dictionary)
65+
cursor.execute(query, query_params)
66+
if is_write_query:
67+
self.conn.commit()
68+
return None
69+
else:
70+
return cursor.fetchall()
4971
except Exception as e:
5072
self.conn.rollback()
5173
self.logger.error(

src/nypl_py_utils/classes/postgresql_client.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import psycopg
22

33
from nypl_py_utils.functions.log_helper import create_log
4+
from psycopg.rows import tuple_row
45
from psycopg_pool import ConnectionPool
56

67

@@ -45,19 +46,43 @@ def connect(self):
4546
'Error connecting to {name} database: {error}'.format(
4647
name=self.db_name, error=e)) from None
4748

48-
def execute_query(self, query):
49+
def execute_query(self, query, is_write_query=False, query_params=None,
50+
row_factory=tuple_row):
4951
"""
5052
Requests a connection from the pool and uses it to execute an arbitrary
5153
query. After the query is complete, returns the connection to the pool.
5254
53-
Returns a sequence of tuples representing the rows returned by the
54-
query.
55+
Parameters
56+
----------
57+
query: str
58+
The query to execute
59+
is_write_query: bool, optional
60+
Whether or not the query is writing to the database, in which case
61+
the transaction needs to be committed and None should be returned
62+
query_params: sequence, optional
63+
The values to be used in a parameterized query
64+
row_factory: RowFactory, optional
65+
A psycopg RowFactory that determines how the data will be returned.
66+
Defaults to tuple_row, which returns the rows as a list of tuples.
67+
68+
Returns
69+
-------
70+
None or sequence
71+
None if is_write_query is True. Some type of sequence based on
72+
the row_factory input if is_write_query is False.
5573
"""
5674
self.logger.info('Querying {} database'.format(self.db_name))
5775
self.logger.debug('Executing query {}'.format(query))
76+
self.pool.check()
5877
with self.pool.connection() as conn:
5978
try:
60-
return conn.execute(query).fetchall()
79+
conn.row_factory = row_factory
80+
cursor = conn.execute(query, query_params)
81+
if is_write_query:
82+
conn.commit()
83+
return None
84+
else:
85+
return cursor.fetchall()
6186
except Exception as e:
6287
conn.rollback()
6388
self.logger.error(

src/nypl_py_utils/classes/redshift_client.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,34 @@ def connect(self):
3232
'Error connecting to {name} database: {error}'.format(
3333
name=self.database, error=e)) from None
3434

35-
def execute_query(self, query):
35+
def execute_query(self, query, dataframe=False):
3636
"""
3737
Executes an arbitrary query against the given database connection.
3838
39-
Returns a sequence of tuples representing the rows returned by the
40-
query.
39+
Parameters
40+
----------
41+
query: str
42+
The query to execute
43+
dataframe: bool, optional
44+
Whether the data will be returned as a pandas DataFrame. Defaults
45+
to False, which means the data is returned as a list of tuples.
46+
47+
Returns
48+
-------
49+
None or sequence
50+
None if is_write_query is True. A list of tuples or a pandas
51+
DataFrame (based on the dataframe input) if is_write_query is
52+
False.
4153
"""
4254
self.logger.info('Querying {} database'.format(self.database))
4355
self.logger.debug('Executing query {}'.format(query))
4456
try:
4557
cursor = self.conn.cursor()
4658
cursor.execute(query)
47-
return cursor.fetchall()
59+
if dataframe:
60+
return cursor.fetch_dataframe()
61+
else:
62+
return cursor.fetchall()
4863
except Exception as e:
4964
self.conn.rollback()
5065
self.logger.error(

tests/test_mysql_client.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def test_connect(self, mock_mysql_conn, test_instance):
2222
user='test_user',
2323
password='test_password')
2424

25-
def test_execute_query(self, mock_mysql_conn, test_instance, mocker):
25+
def test_execute_read_query(self, mock_mysql_conn, test_instance, mocker):
2626
test_instance.connect()
2727

2828
mock_cursor = mocker.MagicMock()
@@ -31,7 +31,56 @@ def test_execute_query(self, mock_mysql_conn, test_instance, mocker):
3131

3232
assert test_instance.execute_query(
3333
'test query') == [(1, 2, 3), ('a', 'b', 'c')]
34-
mock_cursor.execute.assert_called_once_with('test query')
34+
test_instance.conn.cursor.called_once_with(dictionary=False)
35+
mock_cursor.execute.assert_called_once_with('test query', None)
36+
mock_cursor.close.assert_called_once()
37+
38+
def test_execute_dictionary_read_query(self, mock_mysql_conn,
39+
test_instance, mocker):
40+
test_instance.connect()
41+
42+
mock_cursor = mocker.MagicMock()
43+
mock_cursor.fetchall.return_value = [
44+
{'col1': 1, 'col2': 'a'},
45+
{'col1': 2, 'col2': 'b'},
46+
{'col1': 3, 'col2': 'c'}]
47+
test_instance.conn.cursor.return_value = mock_cursor
48+
49+
assert test_instance.execute_query(
50+
'test query', dictionary=True) == [{'col1': 1, 'col2': 'a'},
51+
{'col1': 2, 'col2': 'b'},
52+
{'col1': 3, 'col2': 'c'}]
53+
test_instance.conn.cursor.called_once_with(dictionary=True)
54+
mock_cursor.execute.assert_called_once_with('test query', None)
55+
mock_cursor.close.assert_called_once()
56+
57+
def test_execute_write_query(self, mock_mysql_conn, test_instance, mocker):
58+
test_instance.connect()
59+
60+
mock_cursor = mocker.MagicMock()
61+
test_instance.conn.cursor.return_value = mock_cursor
62+
63+
assert test_instance.execute_query(
64+
'test query', is_write_query=True) is None
65+
test_instance.conn.cursor.called_once_with(dictionary=False)
66+
mock_cursor.execute.assert_called_once_with('test query', None)
67+
test_instance.conn.commit.called_once()
68+
mock_cursor.close.assert_called_once()
69+
70+
def test_execute_write_query_with_params(self, mock_mysql_conn,
71+
test_instance, mocker):
72+
test_instance.connect()
73+
74+
mock_cursor = mocker.MagicMock()
75+
test_instance.conn.cursor.return_value = mock_cursor
76+
77+
assert test_instance.execute_query(
78+
'test query %s %s', is_write_query=True,
79+
query_params=('a', 1)) is None
80+
test_instance.conn.cursor.called_once_with(dictionary=False)
81+
mock_cursor.execute.assert_called_once_with('test query %s %s',
82+
('a', 1))
83+
test_instance.conn.commit.called_once()
3584
mock_cursor.close.assert_called_once()
3685

3786
def test_execute_query_with_exception(

tests/test_postgresql_client.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,54 @@ def test_connect_with_exception(self):
4343
with pytest.raises(PostgreSQLClientError):
4444
test_instance.connect()
4545

46-
def test_execute_query(self, test_instance, mocker):
46+
def test_execute_read_query(self, test_instance, mocker):
47+
test_instance.connect()
48+
49+
mock_cursor = mocker.MagicMock()
50+
mock_cursor.fetchall.return_value = [(1, 2, 3), ('a', 'b', 'c')]
51+
mock_conn = mocker.MagicMock()
52+
mock_conn.execute.return_value = mock_cursor
53+
mock_conn_context = mocker.MagicMock()
54+
mock_conn_context.__enter__.return_value = mock_conn
55+
mocker.patch('psycopg_pool.ConnectionPool.connection',
56+
return_value=mock_conn_context)
57+
58+
assert test_instance.execute_query(
59+
'test query') == [(1, 2, 3), ('a', 'b', 'c')]
60+
mock_conn.execute.assert_called_once_with('test query', None)
61+
mock_cursor.fetchall.assert_called_once()
62+
63+
def test_execute_write_query(self, test_instance, mocker):
64+
test_instance.connect()
65+
66+
mock_conn = mocker.MagicMock()
67+
mock_conn_context = mocker.MagicMock()
68+
mock_conn_context.__enter__.return_value = mock_conn
69+
mocker.patch('psycopg_pool.ConnectionPool.connection',
70+
return_value=mock_conn_context)
71+
72+
assert test_instance.execute_query(
73+
'test query', is_write_query=True) is None
74+
mock_conn.execute.assert_called_once_with('test query', None)
75+
mock_conn.commit.assert_called_once()
76+
77+
def test_execute_write_query_with_params(self, test_instance, mocker):
78+
test_instance.connect()
79+
80+
mock_conn = mocker.MagicMock()
81+
mock_conn_context = mocker.MagicMock()
82+
mock_conn_context.__enter__.return_value = mock_conn
83+
mocker.patch('psycopg_pool.ConnectionPool.connection',
84+
return_value=mock_conn_context)
85+
86+
assert test_instance.execute_query(
87+
'test query %s %s', is_write_query=True,
88+
query_params=('a', 1)) is None
89+
mock_conn.execute.assert_called_once_with('test query %s %s',
90+
('a', 1))
91+
mock_conn.commit.assert_called_once()
92+
93+
def test_execute_query_with_exception(self, test_instance, mocker):
4794
test_instance.connect()
4895

4996
mock_conn = mocker.MagicMock()

tests/test_redshift_client.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ def test_execute_query(self, mock_redshift_conn, test_instance, mocker):
3131
assert test_instance.execute_query(
3232
'test query') == [[1, 2, 3], ['a', 'b', 'c']]
3333
mock_cursor.execute.assert_called_once_with('test query')
34+
mock_cursor.fetchall.assert_called_once()
35+
mock_cursor.close.assert_called_once()
36+
37+
def test_execute_dataframe_query(self, mock_redshift_conn, test_instance,
38+
mocker):
39+
test_instance.connect()
40+
41+
mock_cursor = mocker.MagicMock()
42+
test_instance.conn.cursor.return_value = mock_cursor
43+
44+
test_instance.execute_query('test query', dataframe=True)
45+
mock_cursor.execute.assert_called_once_with('test query')
46+
mock_cursor.fetch_dataframe.assert_called_once()
3447
mock_cursor.close.assert_called_once()
3548

3649
def test_execute_query_with_exception(

0 commit comments

Comments
 (0)