Skip to content

Commit 24eb8dd

Browse files
author
James Robinson
authored
Describe outbound foreign keys in SQL Cell \describe output. (#67)
1 parent 9df8f6f commit 24eb8dd

File tree

3 files changed

+114
-6
lines changed

3 files changed

+114
-6
lines changed

noteable_magics/sql/meta_commands.py

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -455,11 +455,12 @@ def run(self, invoked_as: str, args: List[str]) -> Tuple[DataFrame, bool]:
455455

456456
display(HTML('\n'.join(html_buf)))
457457
else:
458-
# Is a table. Let's go get indices and transform to a dataframe for
459-
# presentation.
460-
index_df = index_dataframe(inspector, relation_name, schema)
461-
if len(index_df):
462-
display(secondary_dataframe_to_html(index_df))
458+
# Is a table. Let's go get indices, foreign keys. If meaningful dataframe returned, transform to
459+
# HTML for presentation (DEX only expects at most a single DF display()ed per cell) and display it.
460+
for secondary_function in (index_dataframe, foreignkeys_dataframe):
461+
secondary_df = secondary_function(inspector, relation_name, schema)
462+
if len(secondary_df):
463+
display(secondary_dataframe_to_html(secondary_df))
463464

464465
return main_relation_df, False
465466

@@ -477,6 +478,44 @@ def _split_schema_table(schema_table: str) -> Tuple[Optional[str], str]:
477478
return (schema, table)
478479

479480

481+
def foreignkeys_dataframe(
482+
inspector: SchemaStrippingInspector, table_name: str, schema: Optional[str]
483+
) -> DataFrame:
484+
"""Transform results from inspector.get_indexes() into a single dataframe for display() purposes"""
485+
486+
names: List[str] = [] # Will be '(unnamed)' if the constraint was not named
487+
constrained_columns: List[str] = [] # Will be comma separated list for compound FKs
488+
referenced_qualified_tables: List[str] = []
489+
referenced_columns: List[str] = [] # Will be comma separated list for compound FKs
490+
491+
fkey_dicts = inspector.get_foreign_keys(table_name, schema)
492+
493+
for fk_dict in fkey_dicts:
494+
if fk_dict['referred_schema']:
495+
# Schema qualify the table.
496+
referred_table = f"{fk_dict['referred_schema']}.{fk_dict['referred_table']}"
497+
else:
498+
referred_table = fk_dict['referred_table']
499+
500+
referenced_qualified_tables.append(referred_table)
501+
names.append(fk_dict.get('name', '(unnamed)'))
502+
constrained_columns.append(', '.join(fk_dict.get('constrained_columns')))
503+
referenced_columns.append(', '.join(fk_dict.get('referred_columns')))
504+
505+
df = DataFrame(
506+
{
507+
'Foreign Key': names,
508+
'Columns': constrained_columns,
509+
'Referenced Table': referenced_qualified_tables,
510+
'Referenced Columns': referenced_columns,
511+
}
512+
)
513+
514+
title = f'Table <code>{displayable_relation_name(schema, table_name)}</code> Foreign Keys'
515+
516+
return set_dataframe_metadata(df, title=title)
517+
518+
480519
def index_dataframe(
481520
inspector: SchemaStrippingInspector, table_name: str, schema: Optional[str]
482521
) -> DataFrame:
@@ -694,6 +733,9 @@ def get_view_definition(self, view_name: str, schema: Optional[str] = None) -> s
694733
def get_pk_constraint(self, table_name: str, schema: Optional[str] = None) -> dict:
695734
return self.underlying_inspector.get_pk_constraint(table_name, schema=schema)
696735

736+
def get_foreign_keys(self, table_name: str, schema: Optional[str] = None) -> List[dict]:
737+
return self.underlying_inspector.get_foreign_keys(table_name, schema=schema)
738+
697739
def get_indexes(self, table_name: str, schema: Optional[str] = None) -> List[dict]:
698740
return self.underlying_inspector.get_indexes(table_name, schema=schema)
699741

tests/conftest.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,10 +200,14 @@ def cleanup_any_extra_tables(connection: Connection):
200200

201201
unexpected_relations = relations - KNOWN_TABLES
202202

203+
# CRDB needs 'cascade' to be able to drop tables referenced with FKs. SQLite does
204+
# not recognize, however.
205+
maybe_cascade = 'cascade' if 'cockroach' in str(connection._engine) else ''
206+
203207
for unexpected_relation in unexpected_relations:
204208
try:
205209
with Session(connection._engine) as db:
206-
db.execute(f'drop table {unexpected_relation}')
210+
db.execute(f'drop table {unexpected_relation} {maybe_cascade}')
207211
db.commit()
208212
except Exception:
209213
# Maybe it was a view?

tests/test_sql_magic_meta_commands.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,68 @@ def test_against_schema_qualified_view(self, sql_magic, ipython_namespace, mock_
424424
'<br />\n<h2>View <code>public.str_int_view</code> Definition</h2>'
425425
), html_contents
426426

427+
@pytest.mark.parametrize(
428+
'handle,schema', [('@cockroach', ''), ('@cockroach', 'public'), ('@sqlite', '')]
429+
)
430+
def test_foreign_keys(self, sql_magic, ipython_namespace, mock_display, handle, schema):
431+
"""Describing table `references_int_table` should talk about a foreign key over to int_table"""
432+
433+
# If was asked with schema qualification, then various outputs will also be schema qualified.
434+
qualified_references_int_table = (
435+
f'{schema}.references_int_table' if schema else 'references_int_table'
436+
)
437+
qualified_int_table = f'{schema}.int_table' if schema else 'int_table'
438+
439+
sql_magic.execute(rf'{handle} \d {qualified_references_int_table}')
440+
441+
assert (
442+
len(mock_display.call_args_list) == 3
443+
) # main structure DF, index DF-as-html, foreign key DF-as-html.
444+
fk_html = mock_display.call_args_list[2].args[0]
445+
assert isinstance(fk_html, HTML)
446+
html_contents: str = fk_html.data
447+
448+
assert html_contents.startswith(
449+
f'<br />\n<h2>Table <code>{qualified_references_int_table}</code> Foreign Keys</h2>'
450+
), html_contents
451+
452+
# Convert the HTML table back to dataframe to complete test.
453+
fk_df = pd.read_html(html_contents)[0]
454+
455+
assert fk_df.columns.tolist() == [
456+
'Foreign Key',
457+
'Columns',
458+
'Referenced Table',
459+
'Referenced Columns',
460+
]
461+
assert fk_df['Columns'].tolist() == ['a_id']
462+
assert fk_df['Referenced Table'].tolist() == [qualified_int_table]
463+
assert fk_df['Referenced Columns'].tolist() == ['a']
464+
465+
# Also test against a table with a compound foreign key. Must create table pair ad hoc. Will be cleaned
466+
# up upon test cleanup.
467+
468+
sql_magic.execute(
469+
f'{handle}\ncreate table {qualified_int_table}_2 (a int, b int, primary key(a, b))'
470+
)
471+
472+
sql_magic.execute(
473+
f'{handle}\ncreate table {qualified_references_int_table}_2 (a_ref int primary key, b_ref int, constraint a_b_fk foreign key (a_ref, b_ref) references {qualified_int_table}_2(a, b))'
474+
)
475+
476+
mock_display.reset_mock()
477+
478+
sql_magic.execute(fr'{handle} \describe {qualified_references_int_table}_2')
479+
480+
assert (
481+
len(mock_display.call_args_list) == 3
482+
) # main structure DF, index DF-as-html, foreign key DF-as-html.
483+
fk2_html = mock_display.call_args_list[2].args[0]
484+
485+
fk2_df = pd.read_html(fk2_html.data)[0]
486+
assert fk2_df['Columns'][0] == 'a_ref, b_ref'
487+
assert fk2_df['Referenced Columns'][0] == 'a, b'
488+
427489
def test_against_table_without_a_primary_key(self, sql_magic, ipython_namespace, mock_display):
428490
# str_table on sqlite will not have any primary key or any indices at all
429491
# (all tables in cockroach have an implicit PK, so can't test with it)

0 commit comments

Comments
 (0)