Skip to content

Commit d462a3c

Browse files
feat: support basic user/password auth with MySQLEngine (#17)
1 parent d1ce730 commit d462a3c

File tree

4 files changed

+150
-10
lines changed

4 files changed

+150
-10
lines changed

integration.cloudbuild.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ steps:
2828
- 'DB_NAME=$_DB_NAME'
2929
- 'TABLE_NAME=test-$BUILD_ID'
3030
- 'REGION=$_REGION'
31+
secretEnv: ['DB_USER', 'DB_PASSWORD']
32+
33+
availableSecrets:
34+
secretManager:
35+
- versionName: projects/$PROJECT_ID/secrets/langchain-test-mysql-username/versions/1
36+
env: 'DB_USER'
37+
- versionName: projects/$PROJECT_ID/secrets/langchain-test-mysql-password/versions/1
38+
env: 'DB_PASSWORD'
3139

3240
substitutions:
3341
_INSTANCE_ID: test-instance

src/langchain_google_cloud_sql_mysql/mysql_engine.py

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,17 @@ def from_instance(
8484
region: str,
8585
instance: str,
8686
database: str,
87+
user: Optional[str] = None,
88+
password: Optional[str] = None,
8789
) -> MySQLEngine:
8890
"""Create an instance of MySQLEngine from Cloud SQL instance
8991
details.
9092
9193
This method uses the Cloud SQL Python Connector to connect to Cloud SQL
9294
using automatic IAM database authentication with the Google ADC
93-
credentials sourced from the environment.
95+
credentials sourced from the environment by default. If user and
96+
password arguments are given, basic database authentication will be
97+
used for database login.
9498
9599
More details can be found at https://github.com/GoogleCloudPlatform/cloud-sql-python-connector#credentials
96100
@@ -101,42 +105,74 @@ def from_instance(
101105
instance (str): The name of the Cloud SQL instance.
102106
database (str): The name of the database to connect to on the
103107
Cloud SQL instance.
108+
user (str, optional): Database user to use for basic database
109+
authentication and login. Defaults to None.
110+
password (str, optional): Database password for 'user' to use for
111+
basic database authentication and login. Defaults to None.
104112
105113
Returns:
106114
(MySQLEngine): The engine configured to connect to a
107115
Cloud SQL instance database.
108116
"""
117+
# error if only one of user or password is set, must be both or neither
118+
if bool(user) ^ bool(password):
119+
raise ValueError(
120+
"Only one of 'user' or 'password' were specified. Either "
121+
"both should be specified to use basic user/password "
122+
"authentication or neither for IAM DB authentication."
123+
)
109124
engine = cls._create_connector_engine(
110125
instance_connection_name=f"{project_id}:{region}:{instance}",
111126
database=database,
127+
user=user,
128+
password=password,
112129
)
113130
return cls(engine=engine)
114131

115132
@classmethod
116133
def _create_connector_engine(
117-
cls, instance_connection_name: str, database: str
134+
cls,
135+
instance_connection_name: str,
136+
database: str,
137+
user: Optional[str],
138+
password: Optional[str],
118139
) -> sqlalchemy.engine.Engine:
119140
"""Create a SQLAlchemy engine using the Cloud SQL Python Connector.
120141
121142
Defaults to use "pymysql" driver and to connect using automatic IAM
122143
database authentication with the IAM principal associated with the
123-
environment's Google Application Default Credentials.
144+
environment's Google Application Default Credentials. If user and
145+
password arguments are given, basic database authentication will be
146+
used for database login.
124147
125148
Args:
126149
instance_connection_name (str): The instance connection
127150
name of the Cloud SQL instance to establish a connection to.
128151
(ex. "project-id:instance-region:instance-name")
129152
database (str): The name of the database to connect to on the
130153
Cloud SQL instance.
154+
user (str, optional): Database user to use for basic database
155+
authentication and login. Defaults to None.
156+
password (str, optional): Database password for 'user' to use for
157+
basic database authentication and login. Defaults to None.
158+
131159
Returns:
132160
(sqlalchemy.engine.Engine): Engine configured using the Cloud SQL
133161
Python Connector.
134162
"""
135-
# get application default credentials
136-
credentials, _ = google.auth.default(
137-
scopes=["https://www.googleapis.com/auth/userinfo.email"]
138-
)
139-
iam_database_user = _get_iam_principal_email(credentials)
163+
# if user and password are given, use basic auth
164+
if user and password:
165+
enable_iam_auth = False
166+
db_user = user
167+
# otherwise use automatic IAM database authentication
168+
else:
169+
# get application default credentials
170+
credentials, _ = google.auth.default(
171+
scopes=["https://www.googleapis.com/auth/userinfo.email"]
172+
)
173+
db_user = _get_iam_principal_email(credentials)
174+
enable_iam_auth = True
175+
140176
if cls._connector is None:
141177
cls._connector = Connector()
142178

@@ -145,9 +181,10 @@ def getconn() -> pymysql.Connection:
145181
conn = cls._connector.connect( # type: ignore
146182
instance_connection_name,
147183
"pymysql",
148-
user=iam_database_user,
184+
user=db_user,
185+
password=password,
149186
db=database,
150-
enable_iam_auth=True,
187+
enable_iam_auth=enable_iam_auth,
151188
)
152189
return conn
153190

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Copyright 2024 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+
import os
15+
16+
import sqlalchemy
17+
18+
from langchain_google_cloud_sql_mysql import MySQLEngine
19+
20+
project_id = os.environ["PROJECT_ID"]
21+
region = os.environ["REGION"]
22+
instance_id = os.environ["INSTANCE_ID"]
23+
db_name = os.environ["DB_NAME"]
24+
db_user = os.environ["DB_USER"]
25+
db_password = os.environ["DB_PASSWORD"]
26+
27+
28+
def test_mysql_engine_with_basic_auth() -> None:
29+
"""Test MySQLEngine works with basic user/password auth."""
30+
# override MySQLEngine._connector to allow a new Connector to be initiated
31+
MySQLEngine._connector = None
32+
engine = MySQLEngine.from_instance(
33+
project_id=project_id,
34+
region=region,
35+
instance=instance_id,
36+
database=db_name,
37+
user=db_user,
38+
password=db_password,
39+
)
40+
# test connection with query
41+
with engine.connect() as conn:
42+
res = conn.execute(sqlalchemy.text("SELECT 1")).fetchone()
43+
conn.commit()
44+
assert res[0] == 1 # type: ignore
45+
# reset MySQLEngine._connector to allow a new Connector to be initiated
46+
MySQLEngine._connector = None

tests/unit/test_engine.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Copyright 2024 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 pytest
16+
17+
from langchain_google_cloud_sql_mysql import MySQLEngine
18+
19+
20+
def test_mysql_engine_with_invalid_arg_pattern() -> None:
21+
"""Test MySQLEngine errors when only one of user or password is given.
22+
23+
Both user and password must be specified (basic authentication)
24+
or neither (IAM authentication).
25+
"""
26+
expected_error_msg = "Only one of 'user' or 'password' were specified. Either both should be specified to use basic user/password authentication or neither for IAM DB authentication."
27+
# test password not set
28+
with pytest.raises(ValueError) as exc_info:
29+
MySQLEngine.from_instance(
30+
project_id="my-project",
31+
region="my-region",
32+
instance="my-instance",
33+
database="my-db",
34+
user="my-user",
35+
)
36+
# assert custom error is present
37+
assert exc_info.value.args[0] == expected_error_msg
38+
39+
# test user not set
40+
with pytest.raises(ValueError) as exc_info:
41+
MySQLEngine.from_instance(
42+
project_id="my-project",
43+
region="my-region",
44+
instance="my-instance",
45+
database="my-db",
46+
password="my-pass",
47+
)
48+
# assert custom error is present
49+
assert exc_info.value.args[0] == expected_error_msg

0 commit comments

Comments
 (0)