databricks-sqlalchemy 0.0.1b1__py3-none-any.whl → 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- CHANGELOG.md +274 -0
- databricks/sqlalchemy/__init__.py +4 -2
- databricks/sqlalchemy/_ddl.py +100 -0
- databricks/sqlalchemy/_parse.py +385 -0
- databricks/sqlalchemy/_types.py +323 -0
- databricks/sqlalchemy/base.py +436 -0
- databricks/sqlalchemy/dependency_test/test_dependency.py +22 -0
- databricks/sqlalchemy/py.typed +0 -0
- databricks/sqlalchemy/pytest.ini +4 -0
- databricks/sqlalchemy/requirements.py +249 -0
- databricks/sqlalchemy/setup.cfg +4 -0
- databricks/sqlalchemy/test/_extra.py +70 -0
- databricks/sqlalchemy/test/_future.py +331 -0
- databricks/sqlalchemy/test/_regression.py +311 -0
- databricks/sqlalchemy/test/_unsupported.py +450 -0
- databricks/sqlalchemy/test/conftest.py +13 -0
- databricks/sqlalchemy/test/overrides/_componentreflectiontest.py +189 -0
- databricks/sqlalchemy/test/overrides/_ctetest.py +33 -0
- databricks/sqlalchemy/test/test_suite.py +13 -0
- databricks/sqlalchemy/test_local/__init__.py +5 -0
- databricks/sqlalchemy/test_local/conftest.py +44 -0
- databricks/sqlalchemy/test_local/e2e/MOCK_DATA.xlsx +0 -0
- databricks/sqlalchemy/test_local/e2e/test_basic.py +543 -0
- databricks/sqlalchemy/test_local/test_ddl.py +96 -0
- databricks/sqlalchemy/test_local/test_parsing.py +160 -0
- databricks/sqlalchemy/test_local/test_types.py +161 -0
- databricks_sqlalchemy-1.0.0.dist-info/LICENSE +201 -0
- databricks_sqlalchemy-1.0.0.dist-info/METADATA +225 -0
- databricks_sqlalchemy-1.0.0.dist-info/RECORD +31 -0
- {databricks_sqlalchemy-0.0.1b1.dist-info → databricks_sqlalchemy-1.0.0.dist-info}/WHEEL +1 -1
- databricks_sqlalchemy-1.0.0.dist-info/entry_points.txt +3 -0
- databricks/__init__.py +0 -7
- databricks_sqlalchemy-0.0.1b1.dist-info/METADATA +0 -19
- databricks_sqlalchemy-0.0.1b1.dist-info/RECORD +0 -5
@@ -0,0 +1,44 @@
|
|
1
|
+
import os
|
2
|
+
import pytest
|
3
|
+
|
4
|
+
|
5
|
+
@pytest.fixture(scope="session")
|
6
|
+
def host():
|
7
|
+
return os.getenv("DATABRICKS_SERVER_HOSTNAME")
|
8
|
+
|
9
|
+
|
10
|
+
@pytest.fixture(scope="session")
|
11
|
+
def http_path():
|
12
|
+
return os.getenv("DATABRICKS_HTTP_PATH")
|
13
|
+
|
14
|
+
|
15
|
+
@pytest.fixture(scope="session")
|
16
|
+
def access_token():
|
17
|
+
return os.getenv("DATABRICKS_TOKEN")
|
18
|
+
|
19
|
+
|
20
|
+
@pytest.fixture(scope="session")
|
21
|
+
def ingestion_user():
|
22
|
+
return os.getenv("DATABRICKS_USER")
|
23
|
+
|
24
|
+
|
25
|
+
@pytest.fixture(scope="session")
|
26
|
+
def catalog():
|
27
|
+
return os.getenv("DATABRICKS_CATALOG")
|
28
|
+
|
29
|
+
|
30
|
+
@pytest.fixture(scope="session")
|
31
|
+
def schema():
|
32
|
+
return os.getenv("DATABRICKS_SCHEMA", "default")
|
33
|
+
|
34
|
+
|
35
|
+
@pytest.fixture(scope="session", autouse=True)
|
36
|
+
def connection_details(host, http_path, access_token, ingestion_user, catalog, schema):
|
37
|
+
return {
|
38
|
+
"host": host,
|
39
|
+
"http_path": http_path,
|
40
|
+
"access_token": access_token,
|
41
|
+
"ingestion_user": ingestion_user,
|
42
|
+
"catalog": catalog,
|
43
|
+
"schema": schema,
|
44
|
+
}
|
Binary file
|
@@ -0,0 +1,543 @@
|
|
1
|
+
import datetime
|
2
|
+
import decimal
|
3
|
+
from typing import Tuple, Union, List
|
4
|
+
from unittest import skipIf
|
5
|
+
|
6
|
+
import pytest
|
7
|
+
from sqlalchemy import (
|
8
|
+
Column,
|
9
|
+
MetaData,
|
10
|
+
Table,
|
11
|
+
Text,
|
12
|
+
create_engine,
|
13
|
+
insert,
|
14
|
+
select,
|
15
|
+
text,
|
16
|
+
)
|
17
|
+
from sqlalchemy.engine import Engine
|
18
|
+
from sqlalchemy.engine.reflection import Inspector
|
19
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
|
20
|
+
from sqlalchemy.schema import DropColumnComment, SetColumnComment
|
21
|
+
from sqlalchemy.types import BOOLEAN, DECIMAL, Date, Integer, String
|
22
|
+
|
23
|
+
try:
|
24
|
+
from sqlalchemy.orm import declarative_base
|
25
|
+
except ImportError:
|
26
|
+
from sqlalchemy.ext.declarative import declarative_base
|
27
|
+
|
28
|
+
|
29
|
+
USER_AGENT_TOKEN = "PySQL e2e Tests"
|
30
|
+
|
31
|
+
|
32
|
+
def sqlalchemy_1_3():
|
33
|
+
import sqlalchemy
|
34
|
+
|
35
|
+
return sqlalchemy.__version__.startswith("1.3")
|
36
|
+
|
37
|
+
|
38
|
+
def version_agnostic_select(object_to_select, *args, **kwargs):
|
39
|
+
"""
|
40
|
+
SQLAlchemy==1.3.x requires arguments to select() to be a Python list
|
41
|
+
|
42
|
+
https://docs.sqlalchemy.org/en/20/changelog/migration_14.html#orm-query-is-internally-unified-with-select-update-delete-2-0-style-execution-available
|
43
|
+
"""
|
44
|
+
|
45
|
+
if sqlalchemy_1_3():
|
46
|
+
return select([object_to_select], *args, **kwargs)
|
47
|
+
else:
|
48
|
+
return select(object_to_select, *args, **kwargs)
|
49
|
+
|
50
|
+
|
51
|
+
def version_agnostic_connect_arguments(connection_details) -> Tuple[str, dict]:
|
52
|
+
HOST = connection_details["host"]
|
53
|
+
HTTP_PATH = connection_details["http_path"]
|
54
|
+
ACCESS_TOKEN = connection_details["access_token"]
|
55
|
+
CATALOG = connection_details["catalog"]
|
56
|
+
SCHEMA = connection_details["schema"]
|
57
|
+
|
58
|
+
ua_connect_args = {"_user_agent_entry": USER_AGENT_TOKEN}
|
59
|
+
|
60
|
+
if sqlalchemy_1_3():
|
61
|
+
conn_string = f"databricks://token:{ACCESS_TOKEN}@{HOST}"
|
62
|
+
connect_args = {
|
63
|
+
**ua_connect_args,
|
64
|
+
"http_path": HTTP_PATH,
|
65
|
+
"server_hostname": HOST,
|
66
|
+
"catalog": CATALOG,
|
67
|
+
"schema": SCHEMA,
|
68
|
+
}
|
69
|
+
|
70
|
+
return conn_string, connect_args
|
71
|
+
else:
|
72
|
+
return (
|
73
|
+
f"databricks://token:{ACCESS_TOKEN}@{HOST}?http_path={HTTP_PATH}&catalog={CATALOG}&schema={SCHEMA}",
|
74
|
+
ua_connect_args,
|
75
|
+
)
|
76
|
+
|
77
|
+
|
78
|
+
@pytest.fixture
|
79
|
+
def db_engine(connection_details) -> Engine:
|
80
|
+
conn_string, connect_args = version_agnostic_connect_arguments(connection_details)
|
81
|
+
return create_engine(conn_string, connect_args=connect_args)
|
82
|
+
|
83
|
+
|
84
|
+
def run_query(db_engine: Engine, query: Union[str, Text]):
|
85
|
+
if not isinstance(query, Text):
|
86
|
+
_query = text(query) # type: ignore
|
87
|
+
else:
|
88
|
+
_query = query # type: ignore
|
89
|
+
with db_engine.begin() as conn:
|
90
|
+
return conn.execute(_query).fetchall()
|
91
|
+
|
92
|
+
|
93
|
+
@pytest.fixture
|
94
|
+
def samples_engine(connection_details) -> Engine:
|
95
|
+
details = connection_details.copy()
|
96
|
+
details["catalog"] = "samples"
|
97
|
+
details["schema"] = "nyctaxi"
|
98
|
+
conn_string, connect_args = version_agnostic_connect_arguments(details)
|
99
|
+
return create_engine(conn_string, connect_args=connect_args)
|
100
|
+
|
101
|
+
|
102
|
+
@pytest.fixture()
|
103
|
+
def base(db_engine):
|
104
|
+
return declarative_base()
|
105
|
+
|
106
|
+
|
107
|
+
@pytest.fixture()
|
108
|
+
def session(db_engine):
|
109
|
+
return Session(db_engine)
|
110
|
+
|
111
|
+
|
112
|
+
@pytest.fixture()
|
113
|
+
def metadata_obj(db_engine):
|
114
|
+
return MetaData()
|
115
|
+
|
116
|
+
|
117
|
+
def test_can_connect(db_engine):
|
118
|
+
simple_query = "SELECT 1"
|
119
|
+
result = run_query(db_engine, simple_query)
|
120
|
+
assert len(result) == 1
|
121
|
+
|
122
|
+
|
123
|
+
def test_connect_args(db_engine):
|
124
|
+
"""Verify that extra connect args passed to sqlalchemy.create_engine are passed to DBAPI
|
125
|
+
|
126
|
+
This will most commonly happen when partners supply a user agent entry
|
127
|
+
"""
|
128
|
+
|
129
|
+
conn = db_engine.connect()
|
130
|
+
connection_headers = conn.connection.thrift_backend._transport._headers
|
131
|
+
user_agent = connection_headers["User-Agent"]
|
132
|
+
|
133
|
+
expected = f"(sqlalchemy + {USER_AGENT_TOKEN})"
|
134
|
+
assert expected in user_agent
|
135
|
+
|
136
|
+
|
137
|
+
@pytest.mark.skipif(sqlalchemy_1_3(), reason="Pandas requires SQLAlchemy >= 1.4")
|
138
|
+
@pytest.mark.skip(
|
139
|
+
reason="DBR is currently limited to 256 parameters per call to .execute(). Test cannot pass."
|
140
|
+
)
|
141
|
+
def test_pandas_upload(db_engine, metadata_obj):
|
142
|
+
import pandas as pd
|
143
|
+
|
144
|
+
SCHEMA = "default"
|
145
|
+
try:
|
146
|
+
df = pd.read_excel(
|
147
|
+
"src/databricks/sqlalchemy/test_local/e2e/demo_data/MOCK_DATA.xlsx"
|
148
|
+
)
|
149
|
+
df.to_sql(
|
150
|
+
"mock_data",
|
151
|
+
db_engine,
|
152
|
+
schema=SCHEMA,
|
153
|
+
index=False,
|
154
|
+
method="multi",
|
155
|
+
if_exists="replace",
|
156
|
+
)
|
157
|
+
|
158
|
+
df_after = pd.read_sql_table("mock_data", db_engine, schema=SCHEMA)
|
159
|
+
assert len(df) == len(df_after)
|
160
|
+
except Exception as e:
|
161
|
+
raise e
|
162
|
+
finally:
|
163
|
+
db_engine.execute("DROP TABLE mock_data")
|
164
|
+
|
165
|
+
|
166
|
+
def test_create_table_not_null(db_engine, metadata_obj: MetaData):
|
167
|
+
table_name = "PySQLTest_{}".format(datetime.datetime.utcnow().strftime("%s"))
|
168
|
+
|
169
|
+
SampleTable = Table(
|
170
|
+
table_name,
|
171
|
+
metadata_obj,
|
172
|
+
Column("name", String(255)),
|
173
|
+
Column("episodes", Integer),
|
174
|
+
Column("some_bool", BOOLEAN, nullable=False),
|
175
|
+
)
|
176
|
+
|
177
|
+
metadata_obj.create_all(db_engine)
|
178
|
+
|
179
|
+
columns = db_engine.dialect.get_columns(
|
180
|
+
connection=db_engine.connect(), table_name=table_name
|
181
|
+
)
|
182
|
+
|
183
|
+
name_column_description = columns[0]
|
184
|
+
some_bool_column_description = columns[2]
|
185
|
+
|
186
|
+
assert name_column_description.get("nullable") is True
|
187
|
+
assert some_bool_column_description.get("nullable") is False
|
188
|
+
|
189
|
+
metadata_obj.drop_all(db_engine)
|
190
|
+
|
191
|
+
|
192
|
+
def test_column_comment(db_engine, metadata_obj: MetaData):
|
193
|
+
table_name = "PySQLTest_{}".format(datetime.datetime.utcnow().strftime("%s"))
|
194
|
+
|
195
|
+
column = Column("name", String(255), comment="some comment")
|
196
|
+
SampleTable = Table(table_name, metadata_obj, column)
|
197
|
+
|
198
|
+
metadata_obj.create_all(db_engine)
|
199
|
+
connection = db_engine.connect()
|
200
|
+
|
201
|
+
columns = db_engine.dialect.get_columns(
|
202
|
+
connection=connection, table_name=table_name
|
203
|
+
)
|
204
|
+
|
205
|
+
assert columns[0].get("comment") == "some comment"
|
206
|
+
|
207
|
+
column.comment = "other comment"
|
208
|
+
connection.execute(SetColumnComment(column))
|
209
|
+
|
210
|
+
columns = db_engine.dialect.get_columns(
|
211
|
+
connection=connection, table_name=table_name
|
212
|
+
)
|
213
|
+
|
214
|
+
assert columns[0].get("comment") == "other comment"
|
215
|
+
|
216
|
+
connection.execute(DropColumnComment(column))
|
217
|
+
|
218
|
+
columns = db_engine.dialect.get_columns(
|
219
|
+
connection=connection, table_name=table_name
|
220
|
+
)
|
221
|
+
|
222
|
+
assert columns[0].get("comment") == None
|
223
|
+
|
224
|
+
metadata_obj.drop_all(db_engine)
|
225
|
+
|
226
|
+
|
227
|
+
def test_bulk_insert_with_core(db_engine, metadata_obj, session):
|
228
|
+
import random
|
229
|
+
|
230
|
+
# Maximum number of parameter is 256. 256/4 == 64
|
231
|
+
num_to_insert = 64
|
232
|
+
|
233
|
+
table_name = "PySQLTest_{}".format(datetime.datetime.utcnow().strftime("%s"))
|
234
|
+
|
235
|
+
names = ["Bim", "Miki", "Sarah", "Ira"]
|
236
|
+
|
237
|
+
SampleTable = Table(
|
238
|
+
table_name, metadata_obj, Column("name", String(255)), Column("number", Integer)
|
239
|
+
)
|
240
|
+
|
241
|
+
rows = [
|
242
|
+
{"name": names[i % 3], "number": random.choice(range(64))}
|
243
|
+
for i in range(num_to_insert)
|
244
|
+
]
|
245
|
+
|
246
|
+
metadata_obj.create_all(db_engine)
|
247
|
+
with db_engine.begin() as conn:
|
248
|
+
conn.execute(insert(SampleTable).values(rows))
|
249
|
+
|
250
|
+
with db_engine.begin() as conn:
|
251
|
+
rows = conn.execute(version_agnostic_select(SampleTable)).fetchall()
|
252
|
+
|
253
|
+
assert len(rows) == num_to_insert
|
254
|
+
|
255
|
+
|
256
|
+
def test_create_insert_drop_table_core(base, db_engine, metadata_obj: MetaData):
|
257
|
+
""" """
|
258
|
+
|
259
|
+
SampleTable = Table(
|
260
|
+
"PySQLTest_{}".format(datetime.datetime.utcnow().strftime("%s")),
|
261
|
+
metadata_obj,
|
262
|
+
Column("name", String(255)),
|
263
|
+
Column("episodes", Integer),
|
264
|
+
Column("some_bool", BOOLEAN),
|
265
|
+
Column("dollars", DECIMAL(10, 2)),
|
266
|
+
)
|
267
|
+
|
268
|
+
metadata_obj.create_all(db_engine)
|
269
|
+
|
270
|
+
insert_stmt = insert(SampleTable).values(
|
271
|
+
name="Bim Adewunmi", episodes=6, some_bool=True, dollars=decimal.Decimal(125)
|
272
|
+
)
|
273
|
+
|
274
|
+
with db_engine.connect() as conn:
|
275
|
+
conn.execute(insert_stmt)
|
276
|
+
|
277
|
+
select_stmt = version_agnostic_select(SampleTable)
|
278
|
+
with db_engine.begin() as conn:
|
279
|
+
resp = conn.execute(select_stmt)
|
280
|
+
|
281
|
+
result = resp.fetchall()
|
282
|
+
|
283
|
+
assert len(result) == 1
|
284
|
+
|
285
|
+
metadata_obj.drop_all(db_engine)
|
286
|
+
|
287
|
+
|
288
|
+
# ORM tests are made following this tutorial
|
289
|
+
# https://docs.sqlalchemy.org/en/14/orm/quickstart.html
|
290
|
+
|
291
|
+
|
292
|
+
@skipIf(False, "Unity catalog must be supported")
|
293
|
+
def test_create_insert_drop_table_orm(db_engine):
|
294
|
+
"""ORM classes built on the declarative base class must have a primary key.
|
295
|
+
This is restricted to Unity Catalog.
|
296
|
+
"""
|
297
|
+
|
298
|
+
class Base(DeclarativeBase):
|
299
|
+
pass
|
300
|
+
|
301
|
+
class SampleObject(Base):
|
302
|
+
__tablename__ = "PySQLTest_{}".format(datetime.datetime.utcnow().strftime("%s"))
|
303
|
+
|
304
|
+
name: Mapped[str] = mapped_column(String(255), primary_key=True)
|
305
|
+
episodes: Mapped[int] = mapped_column(Integer)
|
306
|
+
some_bool: Mapped[bool] = mapped_column(BOOLEAN)
|
307
|
+
|
308
|
+
Base.metadata.create_all(db_engine)
|
309
|
+
|
310
|
+
sample_object_1 = SampleObject(name="Bim Adewunmi", episodes=6, some_bool=True)
|
311
|
+
sample_object_2 = SampleObject(name="Miki Meek", episodes=12, some_bool=False)
|
312
|
+
|
313
|
+
session = Session(db_engine)
|
314
|
+
session.add(sample_object_1)
|
315
|
+
session.add(sample_object_2)
|
316
|
+
session.flush()
|
317
|
+
|
318
|
+
stmt = version_agnostic_select(SampleObject).where(
|
319
|
+
SampleObject.name.in_(["Bim Adewunmi", "Miki Meek"])
|
320
|
+
)
|
321
|
+
|
322
|
+
if sqlalchemy_1_3():
|
323
|
+
output = [i for i in session.execute(stmt)]
|
324
|
+
else:
|
325
|
+
output = [i for i in session.scalars(stmt)]
|
326
|
+
|
327
|
+
assert len(output) == 2
|
328
|
+
|
329
|
+
Base.metadata.drop_all(db_engine)
|
330
|
+
|
331
|
+
|
332
|
+
def test_dialect_type_mappings(db_engine, metadata_obj: MetaData):
|
333
|
+
"""Confirms that we get back the same time we declared in a model and inserted using Core"""
|
334
|
+
|
335
|
+
class Base(DeclarativeBase):
|
336
|
+
pass
|
337
|
+
|
338
|
+
SampleTable = Table(
|
339
|
+
"PySQLTest_{}".format(datetime.datetime.utcnow().strftime("%s")),
|
340
|
+
metadata_obj,
|
341
|
+
Column("string_example", String(255)),
|
342
|
+
Column("integer_example", Integer),
|
343
|
+
Column("boolean_example", BOOLEAN),
|
344
|
+
Column("decimal_example", DECIMAL(10, 2)),
|
345
|
+
Column("date_example", Date),
|
346
|
+
)
|
347
|
+
|
348
|
+
string_example = ""
|
349
|
+
integer_example = 100
|
350
|
+
boolean_example = True
|
351
|
+
decimal_example = decimal.Decimal(125)
|
352
|
+
date_example = datetime.date(2013, 1, 1)
|
353
|
+
|
354
|
+
metadata_obj.create_all(db_engine)
|
355
|
+
|
356
|
+
insert_stmt = insert(SampleTable).values(
|
357
|
+
string_example=string_example,
|
358
|
+
integer_example=integer_example,
|
359
|
+
boolean_example=boolean_example,
|
360
|
+
decimal_example=decimal_example,
|
361
|
+
date_example=date_example,
|
362
|
+
)
|
363
|
+
|
364
|
+
with db_engine.connect() as conn:
|
365
|
+
conn.execute(insert_stmt)
|
366
|
+
|
367
|
+
select_stmt = version_agnostic_select(SampleTable)
|
368
|
+
with db_engine.begin() as conn:
|
369
|
+
resp = conn.execute(select_stmt)
|
370
|
+
|
371
|
+
result = resp.fetchall()
|
372
|
+
this_row = result[0]
|
373
|
+
|
374
|
+
assert this_row.string_example == string_example
|
375
|
+
assert this_row.integer_example == integer_example
|
376
|
+
assert this_row.boolean_example == boolean_example
|
377
|
+
assert this_row.decimal_example == decimal_example
|
378
|
+
assert this_row.date_example == date_example
|
379
|
+
|
380
|
+
metadata_obj.drop_all(db_engine)
|
381
|
+
|
382
|
+
|
383
|
+
def test_inspector_smoke_test(samples_engine: Engine):
|
384
|
+
"""It does not appear that 3L namespace is supported here"""
|
385
|
+
|
386
|
+
schema, table = "nyctaxi", "trips"
|
387
|
+
|
388
|
+
try:
|
389
|
+
inspector = Inspector.from_engine(samples_engine)
|
390
|
+
except Exception as e:
|
391
|
+
assert False, f"Could not build inspector: {e}"
|
392
|
+
|
393
|
+
# Expect six columns
|
394
|
+
columns = inspector.get_columns(table, schema=schema)
|
395
|
+
|
396
|
+
# Expect zero views, but the method should return
|
397
|
+
views = inspector.get_view_names(schema=schema)
|
398
|
+
|
399
|
+
assert (
|
400
|
+
len(columns) == 6
|
401
|
+
), "Dialect did not find the expected number of columns in samples.nyctaxi.trips"
|
402
|
+
assert len(views) == 0, "Views could not be fetched"
|
403
|
+
|
404
|
+
|
405
|
+
@pytest.mark.skip(reason="engine.table_names has been removed in sqlalchemy verison 2")
|
406
|
+
def test_get_table_names_smoke_test(samples_engine: Engine):
|
407
|
+
with samples_engine.connect() as conn:
|
408
|
+
_names = samples_engine.table_names(schema="nyctaxi", connection=conn) # type: ignore
|
409
|
+
_names is not None, "get_table_names did not succeed"
|
410
|
+
|
411
|
+
|
412
|
+
def test_has_table_across_schemas(
|
413
|
+
db_engine: Engine, samples_engine: Engine, catalog: str, schema: str
|
414
|
+
):
|
415
|
+
"""For this test to pass these conditions must be met:
|
416
|
+
- Table samples.nyctaxi.trips must exist
|
417
|
+
- Table samples.tpch.customer must exist
|
418
|
+
- The `catalog` and `schema` environment variables must be set and valid
|
419
|
+
"""
|
420
|
+
|
421
|
+
with samples_engine.connect() as conn:
|
422
|
+
# 1) Check for table within schema declared at engine creation time
|
423
|
+
assert samples_engine.dialect.has_table(connection=conn, table_name="trips")
|
424
|
+
|
425
|
+
# 2) Check for table within another schema in the same catalog
|
426
|
+
assert samples_engine.dialect.has_table(
|
427
|
+
connection=conn, table_name="customer", schema="tpch"
|
428
|
+
)
|
429
|
+
|
430
|
+
# 3) Check for a table within a different catalog
|
431
|
+
# Create a table in a different catalog
|
432
|
+
with db_engine.connect() as conn:
|
433
|
+
conn.execute(text("CREATE TABLE test_has_table (numbers_are_cool INT);"))
|
434
|
+
|
435
|
+
try:
|
436
|
+
# Verify that this table is not found in the samples catalog
|
437
|
+
assert not samples_engine.dialect.has_table(
|
438
|
+
connection=conn, table_name="test_has_table"
|
439
|
+
)
|
440
|
+
# Verify that this table is found in a separate catalog
|
441
|
+
assert samples_engine.dialect.has_table(
|
442
|
+
connection=conn,
|
443
|
+
table_name="test_has_table",
|
444
|
+
schema=schema,
|
445
|
+
catalog=catalog,
|
446
|
+
)
|
447
|
+
finally:
|
448
|
+
conn.execute(text("DROP TABLE test_has_table;"))
|
449
|
+
|
450
|
+
|
451
|
+
def test_user_agent_adjustment(db_engine):
|
452
|
+
# If .connect() is called multiple times on an engine, don't keep pre-pending the user agent
|
453
|
+
# https://github.com/databricks/databricks-sql-python/issues/192
|
454
|
+
c1 = db_engine.connect()
|
455
|
+
c2 = db_engine.connect()
|
456
|
+
|
457
|
+
def get_conn_user_agent(conn):
|
458
|
+
return conn.connection.dbapi_connection.thrift_backend._transport._headers.get(
|
459
|
+
"User-Agent"
|
460
|
+
)
|
461
|
+
|
462
|
+
ua1 = get_conn_user_agent(c1)
|
463
|
+
ua2 = get_conn_user_agent(c2)
|
464
|
+
same_ua = ua1 == ua2
|
465
|
+
|
466
|
+
c1.close()
|
467
|
+
c2.close()
|
468
|
+
|
469
|
+
assert same_ua, f"User agents didn't match \n {ua1} \n {ua2}"
|
470
|
+
|
471
|
+
|
472
|
+
@pytest.fixture
|
473
|
+
def sample_table(metadata_obj: MetaData, db_engine: Engine):
|
474
|
+
"""This fixture creates a sample table and cleans it up after the test is complete."""
|
475
|
+
from databricks.sqlalchemy._parse import GET_COLUMNS_TYPE_MAP
|
476
|
+
|
477
|
+
table_name = "PySQLTest_{}".format(datetime.datetime.utcnow().strftime("%s"))
|
478
|
+
|
479
|
+
args: List[Column] = [
|
480
|
+
Column(colname, coltype) for colname, coltype in GET_COLUMNS_TYPE_MAP.items()
|
481
|
+
]
|
482
|
+
|
483
|
+
SampleTable = Table(table_name, metadata_obj, *args)
|
484
|
+
|
485
|
+
metadata_obj.create_all(db_engine)
|
486
|
+
|
487
|
+
yield table_name
|
488
|
+
|
489
|
+
metadata_obj.drop_all(db_engine)
|
490
|
+
|
491
|
+
|
492
|
+
def test_get_columns(db_engine, sample_table: str):
|
493
|
+
"""Created after PECO-1297 and Github Issue #295 to verify that get_columsn behaves like it should for all known SQLAlchemy types"""
|
494
|
+
|
495
|
+
inspector = Inspector.from_engine(db_engine)
|
496
|
+
|
497
|
+
# this raises an exception if `parse_column_info_from_tgetcolumnsresponse` fails a lookup
|
498
|
+
columns = inspector.get_columns(sample_table)
|
499
|
+
|
500
|
+
assert True
|
501
|
+
|
502
|
+
|
503
|
+
class TestCommentReflection:
|
504
|
+
@pytest.fixture(scope="class")
|
505
|
+
def engine(self, connection_details: dict):
|
506
|
+
HOST = connection_details["host"]
|
507
|
+
HTTP_PATH = connection_details["http_path"]
|
508
|
+
ACCESS_TOKEN = connection_details["access_token"]
|
509
|
+
CATALOG = connection_details["catalog"]
|
510
|
+
SCHEMA = connection_details["schema"]
|
511
|
+
|
512
|
+
connection_string = f"databricks://token:{ACCESS_TOKEN}@{HOST}?http_path={HTTP_PATH}&catalog={CATALOG}&schema={SCHEMA}"
|
513
|
+
connect_args = {"_user_agent_entry": USER_AGENT_TOKEN}
|
514
|
+
|
515
|
+
engine = create_engine(connection_string, connect_args=connect_args)
|
516
|
+
return engine
|
517
|
+
|
518
|
+
@pytest.fixture
|
519
|
+
def inspector(self, engine: Engine) -> Inspector:
|
520
|
+
return Inspector.from_engine(engine)
|
521
|
+
|
522
|
+
@pytest.fixture(scope="class")
|
523
|
+
def table(self, engine):
|
524
|
+
md = MetaData()
|
525
|
+
tbl = Table(
|
526
|
+
"foo",
|
527
|
+
md,
|
528
|
+
Column("bar", String, comment="column comment"),
|
529
|
+
comment="table comment",
|
530
|
+
)
|
531
|
+
md.create_all(bind=engine)
|
532
|
+
|
533
|
+
yield tbl
|
534
|
+
|
535
|
+
md.drop_all(bind=engine)
|
536
|
+
|
537
|
+
def test_table_comment_reflection(self, inspector: Inspector, table: Table):
|
538
|
+
comment = inspector.get_table_comment(table.name)
|
539
|
+
assert comment == {"text": "table comment"}
|
540
|
+
|
541
|
+
def test_column_comment(self, inspector: Inspector, table: Table):
|
542
|
+
result = inspector.get_columns(table.name)[0].get("comment")
|
543
|
+
assert result == "column comment"
|
@@ -0,0 +1,96 @@
|
|
1
|
+
import pytest
|
2
|
+
from sqlalchemy import Column, MetaData, String, Table, create_engine
|
3
|
+
from sqlalchemy.schema import (
|
4
|
+
CreateTable,
|
5
|
+
DropColumnComment,
|
6
|
+
DropTableComment,
|
7
|
+
SetColumnComment,
|
8
|
+
SetTableComment,
|
9
|
+
)
|
10
|
+
|
11
|
+
|
12
|
+
class DDLTestBase:
|
13
|
+
engine = create_engine(
|
14
|
+
"databricks://token:****@****?http_path=****&catalog=****&schema=****"
|
15
|
+
)
|
16
|
+
|
17
|
+
def compile(self, stmt):
|
18
|
+
return str(stmt.compile(bind=self.engine))
|
19
|
+
|
20
|
+
|
21
|
+
class TestColumnCommentDDL(DDLTestBase):
|
22
|
+
@pytest.fixture
|
23
|
+
def metadata(self) -> MetaData:
|
24
|
+
"""Assemble a metadata object with one table containing one column."""
|
25
|
+
metadata = MetaData()
|
26
|
+
|
27
|
+
column = Column("foo", String, comment="bar")
|
28
|
+
table = Table("foobar", metadata, column)
|
29
|
+
|
30
|
+
return metadata
|
31
|
+
|
32
|
+
@pytest.fixture
|
33
|
+
def table(self, metadata) -> Table:
|
34
|
+
return metadata.tables.get("foobar")
|
35
|
+
|
36
|
+
@pytest.fixture
|
37
|
+
def column(self, table) -> Column:
|
38
|
+
return table.columns[0]
|
39
|
+
|
40
|
+
def test_create_table_with_column_comment(self, table):
|
41
|
+
stmt = CreateTable(table)
|
42
|
+
output = self.compile(stmt)
|
43
|
+
|
44
|
+
# output is a CREATE TABLE statement
|
45
|
+
assert "foo STRING COMMENT 'bar'" in output
|
46
|
+
|
47
|
+
def test_alter_table_add_column_comment(self, column):
|
48
|
+
stmt = SetColumnComment(column)
|
49
|
+
output = self.compile(stmt)
|
50
|
+
assert output == "ALTER TABLE foobar ALTER COLUMN foo COMMENT 'bar'"
|
51
|
+
|
52
|
+
def test_alter_table_drop_column_comment(self, column):
|
53
|
+
stmt = DropColumnComment(column)
|
54
|
+
output = self.compile(stmt)
|
55
|
+
assert output == "ALTER TABLE foobar ALTER COLUMN foo COMMENT ''"
|
56
|
+
|
57
|
+
|
58
|
+
class TestTableCommentDDL(DDLTestBase):
|
59
|
+
@pytest.fixture
|
60
|
+
def metadata(self) -> MetaData:
|
61
|
+
"""Assemble a metadata object with one table containing one column."""
|
62
|
+
metadata = MetaData()
|
63
|
+
|
64
|
+
col1 = Column("foo", String)
|
65
|
+
col2 = Column("foo", String)
|
66
|
+
tbl_w_comment = Table("martin", metadata, col1, comment="foobar")
|
67
|
+
tbl_wo_comment = Table("prs", metadata, col2)
|
68
|
+
|
69
|
+
return metadata
|
70
|
+
|
71
|
+
@pytest.fixture
|
72
|
+
def table_with_comment(self, metadata) -> Table:
|
73
|
+
return metadata.tables.get("martin")
|
74
|
+
|
75
|
+
@pytest.fixture
|
76
|
+
def table_without_comment(self, metadata) -> Table:
|
77
|
+
return metadata.tables.get("prs")
|
78
|
+
|
79
|
+
def test_create_table_with_comment(self, table_with_comment):
|
80
|
+
stmt = CreateTable(table_with_comment)
|
81
|
+
output = self.compile(stmt)
|
82
|
+
assert "USING DELTA" in output
|
83
|
+
assert "COMMENT 'foobar'" in output
|
84
|
+
|
85
|
+
def test_alter_table_add_comment(self, table_without_comment: Table):
|
86
|
+
table_without_comment.comment = "wireless mechanical keyboard"
|
87
|
+
stmt = SetTableComment(table_without_comment)
|
88
|
+
output = self.compile(stmt)
|
89
|
+
|
90
|
+
assert output == "COMMENT ON TABLE prs IS 'wireless mechanical keyboard'"
|
91
|
+
|
92
|
+
def test_alter_table_drop_comment(self, table_with_comment):
|
93
|
+
"""The syntax for COMMENT ON is here: https://docs.databricks.com/en/sql/language-manual/sql-ref-syntax-ddl-comment.html"""
|
94
|
+
stmt = DropTableComment(table_with_comment)
|
95
|
+
output = self.compile(stmt)
|
96
|
+
assert output == "COMMENT ON TABLE martin IS NULL"
|