Skip to content

Commit bab9fee

Browse files
authored
[Issue #1279] Add tests for copy oracle data + a script for generating foreign tables (#1352)
## Summary Fixes #1279 ### Time to review: __5 mins__ ## Changes proposed Added a new script for generating foreign data tables which when run locally instead generates normal tables (for testing purposes) Added tests for copy-oracle-data which use this new script copy-oracle-data catches, logs, and throws any exceptions so our monitoring can more easily detect issues ## Context for reviewers This new `setup-foreign-tables` script is maybe a bit hackier than I'd like, but while generating a normal table and a foreign table are similar, there is just enough of a difference (how foreign keys are defined notably) that it required a bit of work to do and so rather than defining the SQL itself, it builds the SQL from the column definitions. It may be worth revisiting how we generate these tables at a later date as we add more of them, but we haven't yet settled on the transformation process so we can punt that discussion for now. ## Additional information Locally, it generates the table as you'd expect when running: ![Screenshot 2024-02-28 at 11 00 56 AM](https://github.com/HHS/simpler-grants-gov/assets/46358556/dd6f6534-5bf4-42ac-8593-f9801efbb857) And the table created: ![Screenshot 2024-02-28 at 11 02 28 AM](https://github.com/HHS/simpler-grants-gov/assets/46358556/2d912687-dd75-4d82-a204-864dcf793220)
1 parent 9cca344 commit bab9fee

File tree

10 files changed

+356
-3
lines changed

10 files changed

+356
-3
lines changed

api/Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,9 @@ openapi-spec: init-db ## Generate OpenAPI spec
243243
copy-oracle-data:
244244
$(FLASK_CMD) data-migration copy-oracle-data
245245

246+
setup-foreign-tables:
247+
$(FLASK_CMD) data-migration setup-foreign-tables
248+
246249
##################################################
247250
# Miscellaneous Utilities
248251
##################################################

api/local.env

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,13 @@ ENABLE_OPPORTUNITY_LOG_MSG=false
8686
############################
8787
# Endpoint Configuration
8888
############################
89-
ENABLE_V_0_1_ENDPOINTS=true
89+
ENABLE_V_0_1_ENDPOINTS=true
90+
91+
############################
92+
# Script Configuration
93+
############################
94+
95+
# For the script to setup the foreign data tables
96+
# this env var overrides it so the script generates normal
97+
# tables that don't need to connect to an Oracle database
98+
IS_LOCAL_FOREIGN_TABLE=true

api/src/data_migration/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22

33
# import any of the other files so they get initialized and attached to the blueprint
44
import src.data_migration.copy_oracle_data # noqa: F401 E402 isort:skip
5+
import src.data_migration.setup_foreign_tables # noqa: F401 E402 isort:skip
56

67
__all__ = ["data_migration_blueprint"]

api/src/data_migration/copy_oracle_data.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,12 @@ class SqlCommands:
5252
def copy_oracle_data(db_session: db.Session) -> None:
5353
logger.info("Beginning copy of data from Oracle database")
5454

55-
with db_session.begin():
56-
_run_copy_commands(db_session)
55+
try:
56+
with db_session.begin():
57+
_run_copy_commands(db_session)
58+
except Exception:
59+
logger.exception("Failed to run copy-oracle-data command")
60+
raise
5761

5862
logger.info("Successfully ran copy-oracle-data")
5963

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import logging
2+
from dataclasses import dataclass
3+
4+
from pydantic import Field
5+
from sqlalchemy import text
6+
7+
import src.adapters.db as db
8+
import src.adapters.db.flask_db as flask_db
9+
from src.data_migration.data_migration_blueprint import data_migration_blueprint
10+
from src.util.env_config import PydanticBaseEnvConfig
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
class ForeignTableConfig(PydanticBaseEnvConfig):
16+
is_local_foreign_table: bool = Field(False)
17+
18+
19+
@dataclass
20+
class Column:
21+
column_name: str
22+
postgres_type: str
23+
24+
is_nullable: bool = True
25+
is_primary_key: bool = False
26+
27+
28+
OPPORTUNITY_COLUMNS: list[Column] = [
29+
Column("OPPORTUNITY_ID", "numeric(20)", is_nullable=False, is_primary_key=True),
30+
Column("OPPNUMBER", "character varying (40)"),
31+
Column("REVISION_NUMBER", "numeric(20)"),
32+
Column("OPPTITLE", "character varying (255)"),
33+
Column("OWNINGAGENCY", "character varying (255)"),
34+
Column("PUBLISHERUID", "character varying (255)"),
35+
Column("LISTED", "CHAR(1)"),
36+
Column("OPPCATEGORY", "CHAR(1)"),
37+
Column("INITIAL_OPPORTUNITY_ID", "numeric(20)"),
38+
Column("MODIFIED_COMMENTS", "character varying (2000)"),
39+
Column("CREATED_DATE", "DATE"),
40+
Column("LAST_UPD_DATE", "DATE"),
41+
Column("CREATOR_ID", "character varying (50)"),
42+
Column("LAST_UPD_ID", "character varying (50)"),
43+
Column("FLAG_2006", "CHAR(1)"),
44+
Column("CATEGORY_EXPLANATION", "character varying (255)"),
45+
Column("PUBLISHER_PROFILE_ID", "numeric(20)"),
46+
Column("IS_DRAFT", "character varying (1)"),
47+
]
48+
49+
50+
@data_migration_blueprint.cli.command(
51+
"setup-foreign-tables", help="Setup the foreign tables for connecting to the Oracle database"
52+
)
53+
@flask_db.with_db_session()
54+
def setup_foreign_tables(db_session: db.Session) -> None:
55+
logger.info("Beginning setup of foreign Oracle tables")
56+
57+
config = ForeignTableConfig()
58+
59+
with db_session.begin():
60+
_run_create_table_commands(db_session, config)
61+
62+
logger.info("Successfully ran setup-foreign-tables")
63+
64+
65+
def build_sql(table_name: str, columns: list[Column], is_local: bool) -> str:
66+
"""
67+
Build the SQL for creating a possibly foreign data table. If running
68+
with is_local, it instead creates a regular table.
69+
70+
Assume you have a table with two columns, an "ID" primary key column, and a "description" text column,
71+
you would call this as::
72+
73+
build_sql("EXAMPLE_TABLE", [Column("ID", "integer", is_nullable=False, is_primary_key=True), Column("DESCRIPTION", "text")], is_local)
74+
75+
Depending on whether the is_local bool is true or false would give two different outputs.
76+
77+
is_local is True::
78+
79+
CREATE TABLE IF NOT EXISTS foreign_example_table (ID integer CONSTRAINT EXAMPLE_TABLE_pkey PRIMARY KEY NOT NULL,DESCRIPTION text)
80+
81+
is_local is False::
82+
83+
CREATE FOREIGN TABLE IF NOT EXISTS foreign_example_table (ID integer OPTIONS (key 'true') NOT NULL,DESCRIPTION text) SERVER grants OPTIONS (schema 'EGRANTSADMIN', table 'EXAMPLE_TABLE')
84+
"""
85+
86+
column_sql_parts = []
87+
for column in columns:
88+
column_sql = f"{column.column_name} {column.postgres_type}"
89+
90+
# Primary keys are defined as constraints in a regular table
91+
# and as options in a foreign data table
92+
if column.is_primary_key and is_local:
93+
column_sql += f" CONSTRAINT {table_name}_pkey PRIMARY KEY"
94+
elif column.is_primary_key and not is_local:
95+
column_sql += " OPTIONS (key 'true')"
96+
97+
if not column.is_nullable:
98+
column_sql += " NOT NULL"
99+
100+
column_sql_parts.append(column_sql)
101+
102+
create_table_command = "CREATE FOREIGN TABLE IF NOT EXISTS"
103+
if is_local:
104+
# Don't make a foreign table if running locally
105+
create_table_command = "CREATE TABLE IF NOT EXISTS"
106+
107+
create_command_suffix = (
108+
f" SERVER grants OPTIONS (schema 'EGRANTSADMIN', table '{table_name}')" # noqa: B907
109+
)
110+
if is_local:
111+
# We don't want the config at the end if we're running locally so unset it
112+
create_command_suffix = ""
113+
114+
return f"{create_table_command} foreign_{table_name.lower()} ({','.join(column_sql_parts)}){create_command_suffix}"
115+
116+
117+
def _run_create_table_commands(db_session: db.Session, config: ForeignTableConfig) -> None:
118+
db_session.execute(
119+
text(build_sql("TOPPORTUNITY", OPPORTUNITY_COLUMNS, config.is_local_foreign_table))
120+
)

api/src/db/migrations/env.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ def include_object(
3838
) -> bool:
3939
if type_ == "schema" and getattr(object, "schema", None) is not None:
4040
return False
41+
if type_ == "table" and name is not None and name.startswith("foreign_"):
42+
# We create foreign tables to an Oracle database, if we see those locally
43+
# just ignore them as they aren't something we want included in Alembic
44+
return False
4145
else:
4246
return True
4347

api/tests/src/data_migration/__init__.py

Whitespace-only changes.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from datetime import date
2+
3+
import pytest
4+
from sqlalchemy import text
5+
6+
from src.data_migration.copy_oracle_data import _run_copy_commands
7+
from src.data_migration.setup_foreign_tables import ForeignTableConfig, _run_create_table_commands
8+
from src.db.models.transfer.topportunity_models import TransferTopportunity
9+
from tests.src.db.models.factories import ForeignTopportunityFactory, TransferTopportunityFactory
10+
11+
12+
@pytest.fixture(autouse=True)
13+
def setup_foreign_tables(db_session):
14+
_run_create_table_commands(db_session, ForeignTableConfig(is_local_foreign_table=True))
15+
16+
17+
@pytest.fixture(autouse=True, scope="function")
18+
def truncate_tables(db_session):
19+
# Automatically delete all the data in the relevant tables before tests
20+
db_session.execute(text("TRUNCATE TABLE foreign_topportunity"))
21+
db_session.execute(text("TRUNCATE TABLE transfer_topportunity"))
22+
23+
24+
def convert_value_for_insert(value) -> str:
25+
if value is None:
26+
return "NULL"
27+
28+
if isinstance(value, int):
29+
return str(value)
30+
if isinstance(value, str):
31+
return f"'{value}'" # noqa: B907
32+
if isinstance(value, date):
33+
return f"'{value.isoformat()}'" # noqa: B907
34+
35+
raise Exception("Type not configured for conversion")
36+
37+
38+
def build_foreign_opportunity(db_session, opp_params: dict):
39+
opp = ForeignTopportunityFactory.build(**opp_params)
40+
41+
columns = opp.keys()
42+
values = [convert_value_for_insert(opp[column]) for column in columns]
43+
44+
db_session.execute(
45+
text(
46+
f"INSERT INTO foreign_topportunity ({','.join(columns)}) VALUES ({','.join(values)})" # nosec
47+
)
48+
)
49+
50+
return opp
51+
52+
53+
def test_copy_oracle_data_foreign_empty(db_session, enable_factory_create):
54+
TransferTopportunityFactory.create_batch(size=5)
55+
# The foreign table is empty, so this just truncates the transfer table
56+
assert db_session.query(TransferTopportunity).count() == 5
57+
_run_copy_commands(db_session)
58+
assert db_session.query(TransferTopportunity).count() == 0
59+
60+
61+
def test_copy_oracle_data(db_session, enable_factory_create):
62+
print(db_session.__class__.__name__)
63+
64+
# Create some records initially in the table that we'll wipe
65+
TransferTopportunityFactory.create_batch(size=3)
66+
67+
foreign_records = [
68+
build_foreign_opportunity(db_session, {}),
69+
build_foreign_opportunity(db_session, {}),
70+
build_foreign_opportunity(db_session, {}),
71+
build_foreign_opportunity(db_session, {"oppnumber": "ABC-123-454-321-CBA"}),
72+
build_foreign_opportunity(db_session, {"opportunity_id": 100}),
73+
]
74+
75+
# The copy script won't fetch anything with is_draft not equaling "N" so add one
76+
build_foreign_opportunity(db_session, {"is_draft": "Y"})
77+
78+
_run_copy_commands(db_session)
79+
80+
copied_opportunities = db_session.query(TransferTopportunity).all()
81+
82+
assert len(copied_opportunities) == len(foreign_records)
83+
84+
copied_opportunities.sort(key=lambda opportunity: opportunity.opportunity_id)
85+
foreign_records.sort(key=lambda opportunity: opportunity["opportunity_id"])
86+
87+
for copied_opportunity, foreign_record in zip(
88+
copied_opportunities, foreign_records, strict=True
89+
):
90+
assert copied_opportunity.opportunity_id == foreign_record["opportunity_id"]
91+
assert copied_opportunity.oppnumber == foreign_record["oppnumber"]
92+
assert copied_opportunity.opptitle == foreign_record["opptitle"]
93+
assert copied_opportunity.owningagency == foreign_record["owningagency"]
94+
assert copied_opportunity.oppcategory == foreign_record["oppcategory"]
95+
assert copied_opportunity.category_explanation == foreign_record["category_explanation"]
96+
assert copied_opportunity.is_draft == foreign_record["is_draft"]
97+
assert copied_opportunity.revision_number == foreign_record["revision_number"]
98+
assert copied_opportunity.last_upd_date == foreign_record["last_upd_date"]
99+
assert copied_opportunity.created_date == foreign_record["created_date"]
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import pytest
2+
3+
from src.data_migration.setup_foreign_tables import OPPORTUNITY_COLUMNS, Column, build_sql
4+
5+
EXPECTED_LOCAL_OPPORTUNITY_SQL = (
6+
"CREATE TABLE IF NOT EXISTS foreign_topportunity "
7+
"(OPPORTUNITY_ID numeric(20) CONSTRAINT TOPPORTUNITY_pkey PRIMARY KEY NOT NULL,"
8+
"OPPNUMBER character varying (40),"
9+
"REVISION_NUMBER numeric(20),"
10+
"OPPTITLE character varying (255),"
11+
"OWNINGAGENCY character varying (255),"
12+
"PUBLISHERUID character varying (255),"
13+
"LISTED CHAR(1),"
14+
"OPPCATEGORY CHAR(1),"
15+
"INITIAL_OPPORTUNITY_ID numeric(20),"
16+
"MODIFIED_COMMENTS character varying (2000),"
17+
"CREATED_DATE DATE,"
18+
"LAST_UPD_DATE DATE,"
19+
"CREATOR_ID character varying (50),"
20+
"LAST_UPD_ID character varying (50),"
21+
"FLAG_2006 CHAR(1),"
22+
"CATEGORY_EXPLANATION character varying (255),"
23+
"PUBLISHER_PROFILE_ID numeric(20),"
24+
"IS_DRAFT character varying (1))"
25+
)
26+
27+
EXPECTED_NONLOCAL_OPPORTUNITY_SQL = (
28+
"CREATE FOREIGN TABLE IF NOT EXISTS foreign_topportunity "
29+
"(OPPORTUNITY_ID numeric(20) OPTIONS (key 'true') NOT NULL,"
30+
"OPPNUMBER character varying (40),"
31+
"REVISION_NUMBER numeric(20),"
32+
"OPPTITLE character varying (255),"
33+
"OWNINGAGENCY character varying (255),"
34+
"PUBLISHERUID character varying (255),"
35+
"LISTED CHAR(1),"
36+
"OPPCATEGORY CHAR(1),"
37+
"INITIAL_OPPORTUNITY_ID numeric(20),"
38+
"MODIFIED_COMMENTS character varying (2000),"
39+
"CREATED_DATE DATE,"
40+
"LAST_UPD_DATE DATE,"
41+
"CREATOR_ID character varying (50),"
42+
"LAST_UPD_ID character varying (50),"
43+
"FLAG_2006 CHAR(1),"
44+
"CATEGORY_EXPLANATION character varying (255),"
45+
"PUBLISHER_PROFILE_ID numeric(20),"
46+
"IS_DRAFT character varying (1))"
47+
" SERVER grants OPTIONS (schema 'EGRANTSADMIN', table 'TOPPORTUNITY')"
48+
)
49+
50+
51+
TEST_COLUMNS = [
52+
Column("ID", "integer", is_nullable=False, is_primary_key=True),
53+
Column("DESCRIPTION", "text"),
54+
]
55+
EXPECTED_LOCAL_TEST_SQL = (
56+
"CREATE TABLE IF NOT EXISTS foreign_test_table "
57+
"(ID integer CONSTRAINT TEST_TABLE_pkey PRIMARY KEY NOT NULL,"
58+
"DESCRIPTION text)"
59+
)
60+
EXPECTED_NONLOCAL_TEST_SQL = (
61+
"CREATE FOREIGN TABLE IF NOT EXISTS foreign_test_table "
62+
"(ID integer OPTIONS (key 'true') NOT NULL,"
63+
"DESCRIPTION text)"
64+
" SERVER grants OPTIONS (schema 'EGRANTSADMIN', table 'TEST_TABLE')"
65+
)
66+
67+
68+
@pytest.mark.parametrize(
69+
"table_name,columns,is_local,expected_sql",
70+
[
71+
("TEST_TABLE", TEST_COLUMNS, True, EXPECTED_LOCAL_TEST_SQL),
72+
("TEST_TABLE", TEST_COLUMNS, False, EXPECTED_NONLOCAL_TEST_SQL),
73+
("TOPPORTUNITY", OPPORTUNITY_COLUMNS, True, EXPECTED_LOCAL_OPPORTUNITY_SQL),
74+
("TOPPORTUNITY", OPPORTUNITY_COLUMNS, False, EXPECTED_NONLOCAL_OPPORTUNITY_SQL),
75+
],
76+
)
77+
def test_build_sql(table_name, columns, is_local, expected_sql):
78+
sql = build_sql(table_name, columns, is_local)
79+
80+
assert sql == expected_sql

api/tests/src/db/models/factories.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,36 @@ class Meta:
277277

278278
created_date = factory.LazyAttribute(lambda o: o.created_at.date())
279279
last_upd_date = factory.LazyAttribute(lambda o: o.updated_at.date())
280+
281+
282+
####################################
283+
# Foreign Table Factories
284+
####################################
285+
286+
287+
class ForeignTopportunityFactory(factory.DictFactory):
288+
"""
289+
NOTE: This generates a dictionary - and does not connect to the database directly
290+
"""
291+
292+
opportunity_id = factory.Sequence(lambda n: n)
293+
294+
oppnumber = factory.Sequence(lambda n: f"F-ABC-{n}-XYZ-001")
295+
opptitle = factory.LazyFunction(lambda: f"Research into {fake.job()} industry")
296+
297+
owningagency = factory.Iterator(["F-US-ABC", "F-US-XYZ", "F-US-123"])
298+
299+
oppcategory = factory.fuzzy.FuzzyChoice(OpportunityCategoryLegacy)
300+
# only set the category explanation if category is Other
301+
category_explanation = factory.Maybe(
302+
decider=factory.LazyAttribute(lambda o: o.oppcategory == OpportunityCategoryLegacy.OTHER),
303+
yes_declaration=factory.Sequence(lambda n: f"Category as chosen by order #{n * n - 1}"),
304+
no_declaration=None,
305+
)
306+
307+
is_draft = "N" # Because we filter out drafts, just default these to False
308+
309+
revision_number = 0
310+
311+
created_date = factory.Faker("date_between", start_date="-10y", end_date="-5y")
312+
last_upd_date = factory.Faker("date_between", start_date="-5y", end_date="today")

0 commit comments

Comments
 (0)