Skip to content

Commit 266b254

Browse files
authored
Add Cloud SQL MySQL connectivity samples for SQLAlchemy. (#1828)
* Add connectivity sample for SQLAlchemy. * Address feedback.
1 parent d295f9f commit 266b254

File tree

5 files changed

+370
-0
lines changed

5 files changed

+370
-0
lines changed

cloud-sql/mysql/sqlalchemy/README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Connecting to Cloud SQL - MySQL
2+
3+
## Before you begin
4+
5+
1. If you haven't already, set up a Python Development Environment by following the [python setup guide](https://cloud.google.com/python/setup) and
6+
[create a project](https://cloud.google.com/resource-manager/docs/creating-managing-projects#creating_a_project).
7+
8+
1. Create a 2nd Gen Cloud SQL Instance by following these
9+
[instructions](https://cloud.google.com/sql/docs/mysql/create-instance). Note the connection string,
10+
database user, and database password that you create.
11+
12+
1. Create a database for your application by following these
13+
[instructions](https://cloud.google.com/sql/docs/mysql/create-manage-databases). Note the database
14+
name.
15+
16+
1. Create a service account with the 'Cloud SQL Client' permissions by following these
17+
[instructions](https://cloud.google.com/sql/docs/mysql/connect-external-app#4_if_required_by_your_authentication_method_create_a_service_account).
18+
Download a JSON key to use to authenticate your connection.
19+
20+
1. Use the information noted in the previous steps:
21+
```bash
22+
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service/account/key.json
23+
export CLOUD_SQL_CONNECTION_NAME='<MY-PROJECT>:<INSTANCE-REGION>:<MY-DATABASE>'
24+
export DB_USER='my-db-user'
25+
export DB_PASS='my-db-pass'
26+
export DB_NAME='my_db'
27+
```
28+
Note: Saving credentials in environment variables is convenient, but not secure - consider a more
29+
secure solution such as [Cloud KMS](https://cloud.google.com/kms/) to help keep secrets safe.
30+
31+
## Running locally
32+
33+
To run this application locally, download and install the `cloud_sql_proxy` by
34+
following the instructions [here](https://cloud.google.com/sql/docs/mysql/sql-proxy#install).
35+
36+
Once the proxy is ready, use the following command to start the proxy in the
37+
background:
38+
```bash
39+
./cloud_sql_proxy -dir=/cloudsql --instances=$CLOUD_SQL_CONNECTION_NAME --credential_file=$GOOGLE_APPLICATION_CREDENTIALS
40+
```
41+
Note: Make sure to run the command under a user with write access in the
42+
`/cloudsql` directory. This proxy will use this folder to create a unix socket
43+
the application will use to connect to Cloud SQL.
44+
45+
Next, setup install the requirements into a virtual enviroment:
46+
```bash
47+
virtualenv --python python3 env
48+
source env/bin/activate
49+
pip install -r requirements.txt
50+
```
51+
52+
Finally, start the application:
53+
```bash
54+
python main.py
55+
```
56+
57+
Navigate towards `http://127.0.0.1:8080` to verify your application is running correctly.
58+
59+
## Google App Engine Standard
60+
61+
To run on GAE-Standard, create an App Engine project by following the setup for these
62+
[instructions](https://cloud.google.com/appengine/docs/standard/python3/quickstart#before-you-begin).
63+
64+
First, update `app.yaml` with the correct values to pass the environment
65+
variables into the runtime.
66+
67+
Next, the following command will deploy the application to your Google Cloud project:
68+
```bash
69+
gcloud app deploy
70+
```

cloud-sql/mysql/sqlalchemy/app.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Copyright 2018 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
runtime: python37
16+
17+
# Remember - storing secrets in plaintext is potentially unsafe. Consider using
18+
# something like https://cloud.google.com/kms/ to help keep secrets secret.
19+
env_variables:
20+
CLOUD_SQL_INSTANCE_NAME: <MY-PROJECT>:<INSTANCE-REGION>:<MY-DATABASE>
21+
DB_USER: my-db-user
22+
DB_PASS: my-db-pass
23+
DB_NAME: my_db

cloud-sql/mysql/sqlalchemy/main.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# Copyright 2018 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import datetime
16+
import logging
17+
import os
18+
19+
from flask import Flask, render_template, request, Response
20+
import sqlalchemy
21+
22+
23+
# Remember - storing secrets in plaintext is potentially unsafe. Consider using
24+
# something like https://cloud.google.com/kms/ to help keep secrets secret.
25+
db_user = os.environ.get("DB_USER")
26+
db_pass = os.environ.get("DB_PASS")
27+
db_name = os.environ.get("DB_NAME")
28+
cloud_sql_instance_name = os.environ.get("CLOUD_SQL_INSTANCE_NAME")
29+
30+
app = Flask(__name__)
31+
32+
logger = logging.getLogger()
33+
34+
# [START cloud_sql_mysql_connection_pool]
35+
# The SQLAlchemy engine will help manage interactions, including automatically
36+
# managing a pool of connections to your database
37+
db = sqlalchemy.create_engine(
38+
# Equivalent URL:
39+
# mysql+pymysql://<db_user>:<db_pass>@/<db_name>?unix_socket=/cloudsql/<cloud_sql_instance_name>
40+
sqlalchemy.engine.url.URL(
41+
drivername='mysql+pymysql',
42+
username=db_user,
43+
password=db_pass,
44+
database=db_name,
45+
query={
46+
'unix_socket': '/cloudsql/{}'.format(cloud_sql_instance_name)
47+
}
48+
),
49+
# ... Specify additional properties here.
50+
# [START_EXCLUDE]
51+
52+
# [START cloud_sql_mysql_limit_connections]
53+
# Pool size is the maximum number of permanent connections to keep.
54+
pool_size=5,
55+
# Temporarily exceeds the set pool_size if no connections are available.
56+
max_overflow=2,
57+
# The total number of concurrent connections for your application will be
58+
# a total of pool_size and max_overflow.
59+
# [END cloud_sql_mysql_limit_connections]
60+
61+
# [START cloud_sql_mysql_connection_backoff]
62+
# SQLAlchemy automatically uses delays between failed connection attempts,
63+
# but provides no arguments for configuration.
64+
# [END cloud_sql_mysql_connection_backoff]
65+
66+
# [START cloud_sql_mysql_connection_timeout]
67+
# 'pool_timeout' is the maximum number of seconds to wait when retrieving a
68+
# new connection from the pool. After the specified amount of time, an
69+
# exception will be thrown.
70+
pool_timeout=30, # 30 seconds
71+
# [END cloud_sql_mysql_connection_timeout]
72+
73+
# [START cloud_sql_mysql_connection_lifetime]
74+
# 'pool_recycle' is the maximum number of seconds a connection can persist.
75+
# Connections that live longer than the specified amount of time will be
76+
# reestablished
77+
pool_recycle=1800, # 30 minutes
78+
# [END cloud_sql_mysql_connection_lifetime]
79+
80+
# [END_EXCLUDE]
81+
)
82+
# [END cloud_sql_mysql_connection_pool]
83+
84+
85+
@app.before_first_request
86+
def create_tables():
87+
# Create tables (if they don't already exist)
88+
with db.connect() as conn:
89+
conn.execute(
90+
"CREATE TABLE IF NOT EXISTS votes "
91+
"( vote_id SERIAL NOT NULL, time_cast timestamp NOT NULL, "
92+
"candidate CHAR(6) NOT NULL, PRIMARY KEY (vote_id) );"
93+
)
94+
95+
96+
@app.route('/', methods=['GET'])
97+
def index():
98+
votes = []
99+
with db.connect() as conn:
100+
# Execute the query and fetch all results
101+
recent_votes = conn.execute(
102+
"SELECT candidate, time_cast FROM votes "
103+
"ORDER BY time_cast DESC LIMIT 5"
104+
).fetchall()
105+
# Convert the results into a list of dicts representing votes
106+
for row in recent_votes:
107+
votes.append({
108+
'candidate': row[0],
109+
'time_cast': row[1]
110+
})
111+
112+
stmt = sqlalchemy.text(
113+
"SELECT COUNT(vote_id) FROM votes WHERE candidate=:candidate")
114+
# Count number of votes for tabs
115+
tab_result = conn.execute(stmt, candidate="TABS").fetchone()
116+
tab_count = tab_result[0]
117+
# Count number of votes for spaces
118+
space_result = conn.execute(stmt, candidate="SPACES").fetchone()
119+
space_count = space_result[0]
120+
121+
return render_template(
122+
'index.html',
123+
recent_votes=votes,
124+
tab_count=tab_count,
125+
space_count=space_count
126+
)
127+
128+
129+
@app.route('/', methods=['POST'])
130+
def save_vote():
131+
# Get the team and time the vote was cast.
132+
team = request.form['team']
133+
time_cast = datetime.datetime.utcnow()
134+
# Verify that the team is one of the allowed options
135+
if team != "TABS" and team != "SPACES":
136+
logger.warning(team)
137+
return Response(
138+
response="Invalid team specified.",
139+
status=400
140+
)
141+
142+
# [START cloud_sql_mysql_example_statement]
143+
# Preparing a statement before hand can help protect against injections.
144+
stmt = sqlalchemy.text(
145+
"INSERT INTO votes (time_cast, candidate)"
146+
" VALUES (:time_cast, :candidate)"
147+
)
148+
try:
149+
# Using a with statement ensures that the connection is always released
150+
# back into the pool at the end of statement (even if an error occurs)
151+
with db.connect() as conn:
152+
conn.execute(stmt, time_cast=time_cast, candidate=team)
153+
except Exception as e:
154+
# If something goes wrong, handle the error in this section. This might
155+
# involve retrying or adjusting parameters depending on the situation.
156+
# [START_EXCLUDE]
157+
logger.exception(e)
158+
return Response(
159+
status=500,
160+
response="Unable to successfully cast vote! Please check the "
161+
"application logs for more details."
162+
)
163+
# [END_EXCLUDE]
164+
# [END cloud_sql_mysql_example_statement]
165+
166+
return Response(
167+
status=200,
168+
response="Vote successfully cast for '{}' at time {}!".format(
169+
team, time_cast)
170+
)
171+
172+
173+
if __name__ == '__main__':
174+
app.run(host='127.0.0.1', port=8080, debug=True)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Flask==1.0.2
2+
SQLAlchemy==1.2.13
3+
PyMySQL==0.9.2
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<!--
2+
Copyright 2018 Google LLC
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
-->
16+
<html lang="en">
17+
<head>
18+
<title>Tabs VS Spaces</title>
19+
<link rel="stylesheet"
20+
href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
21+
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
22+
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
23+
</head>
24+
<body>
25+
<nav class="red lighten-1">
26+
<div class="nav-wrapper">
27+
<a href="#" class="brand-logo center">Tabs VS Spaces</a>
28+
</div>
29+
</nav>
30+
<div class="section">
31+
<div class="center">
32+
<h4>
33+
{% if tab_count == space_count %}
34+
TABS and SPACES are evenly matched!
35+
{% elif tab_count > space_count %}
36+
TABS are winning by {{tab_count - space_count}}
37+
{{'votes' if tab_count - space_count > 1 else 'vote'}}!
38+
{% elif space_count > tab_count %}
39+
SPACES are winning by {{space_count - tab_count}}
40+
{{'votes' if space_count - tab_count > 1 else 'vote'}}!
41+
{% endif %}
42+
</h4>
43+
</div>
44+
<div class="row center">
45+
<div class="col s6 m5 offset-m1">
46+
<div class="card-panel {{'green lighten-3' if tab_count > space_count}}">
47+
<i class="material-icons large">keyboard_tab</i>
48+
<h3>{{tab_count}} votes</h3>
49+
<button id="voteTabs" class="btn green">Vote for TABS</button>
50+
</div>
51+
</div>
52+
<div class="col s6 m5">
53+
<div class="card-panel {{'blue lighten-3' if tab_count < space_count}}">
54+
<i class="material-icons large">space_bar</i>
55+
<h3>{{space_count}} votes</h3>
56+
<button id="voteSpaces" class="btn blue">Vote for SPACES</button>
57+
</div>
58+
</div>
59+
</div>
60+
<h4 class="header center">Recent Votes</h4>
61+
<ul class="container collection center">
62+
{% for vote in recent_votes %}
63+
<li class="collection-item avatar">
64+
{% if vote.candidate == "TABS" %}
65+
<i class="material-icons circle green">keyboard_tab</i>
66+
{% elif vote.candidate == "SPACES" %}
67+
<i class="material-icons circle blue">space_bar</i>
68+
{% endif %}
69+
<span class="title">
70+
A vote for <b>{{vote.candidate}}</b>
71+
</span>
72+
<p>was cast at {{vote.time_cast}}</p>
73+
</li>
74+
{% endfor %}
75+
</ul>
76+
</div>
77+
<script>
78+
function vote(team) {
79+
var xhr = new XMLHttpRequest();
80+
xhr.onreadystatechange = function () {
81+
if (this.readyState == 4) {
82+
if (!window.alert(this.responseText)) {
83+
window.location.reload();
84+
}
85+
}
86+
};
87+
xhr.open("POST", "/", true);
88+
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
89+
xhr.send("team=" + team);
90+
}
91+
92+
document.getElementById("voteTabs").addEventListener("click", function () {
93+
vote("TABS");
94+
});
95+
document.getElementById("voteSpaces").addEventListener("click", function () {
96+
vote("SPACES");
97+
});
98+
</script>
99+
</body>
100+
</html>

0 commit comments

Comments
 (0)