"""Step definitions for postgresql_helpers_coverage.feature. These steps target specific uncovered lines in _postgresql_helpers.py: - Line 50: ident_pair with quoted identifier containing escaped double-quotes - Line 192: _skip_quoted with escaped quotes inside strings (j += 2) - Line 197: _skip_quoted with unclosed quote (return n) - Lines 198-204: _skip_quoted with dollar-quoting ($$ ... $$) - Line 215: extract_body with invalid paren_start - Line 230: extract_body with unbalanced parentheses - Lines 384-385, 387-388: extract_fk_triples column count mismatch warning - Line 434: parse_table_body empty entry continue """ import logging from behave import given, then, when from cleveragents.domain.models.acms._postgresql_helpers import ( CREATE_TABLE_RE, FOREIGN_KEY_RE, _skip_quoted, extract_body, extract_fk_triples, ident_pair, parse_table_body, split_entries, table_uri, ) # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("the postgresql helpers module is imported") def step_module_imported(context): """Ensure the module is importable.""" assert extract_body is not None assert split_entries is not None assert ident_pair is not None # --------------------------------------------------------------------------- # ident_pair: quoted identifier with escaped double-quotes (line 50) # --------------------------------------------------------------------------- @given("a regex match with a quoted identifier containing escaped double-quotes") def step_quoted_ident_match(context): """Create a regex match where the quoted branch has double-double-quotes. We use CREATE_TABLE_RE against a CREATE TABLE statement with a quoted schema name containing escaped double-quotes: "my""schema"."table" """ # _IDENT produces two groups each: (quoted, unquoted) # CREATE_TABLE_RE groups: 1,2 = schema; 3,4 = table sql = 'CREATE TABLE "my""schema"."table" (' m = CREATE_TABLE_RE.search(sql) assert m is not None, f"CREATE_TABLE_RE did not match: {sql!r}" context.ident_match = m @when("I call ident_pair on the quoted match") def step_call_ident_pair_quoted(context): """Call ident_pair targeting the quoted schema groups.""" # Groups 1,2 are the schema pair in CREATE_TABLE_RE context.ident_result = ident_pair(context.ident_match, 1, 2) @then("the result should have double-quotes unescaped") def step_verify_unescaped(context): """Verify that '""' was replaced with '"'.""" assert context.ident_result == 'my"schema', ( f"Expected 'my\"schema', got {context.ident_result!r}" ) # --------------------------------------------------------------------------- # _skip_quoted: escaped single-quotes (line 192) # --------------------------------------------------------------------------- @given("SQL content with parenthesized body containing escaped single-quotes") def step_content_escaped_single_quotes(context): """Build content with a string containing '' (escaped single-quote). E.g.: (col1 TEXT DEFAULT 'it''s fine', col2 INT) The '' inside the string should be skipped by _skip_quoted. """ context.paren_content = "(col1 TEXT DEFAULT 'it''s fine', col2 INT)" context.paren_start = 0 @when("I call extract_body on the content") def step_call_extract_body(context): """Call extract_body with the content.""" context.extracted_body = extract_body(context.paren_content, context.paren_start) @then("the body should be extracted correctly with the quoted string intact") def step_verify_body_with_quotes(context): """Verify the body was extracted including the full quoted string.""" assert "col1 TEXT DEFAULT 'it''s fine'" in context.extracted_body, ( f"Body missing escaped-quote string: {context.extracted_body!r}" ) assert "col2 INT" in context.extracted_body @given("a table body with escaped double-quotes in an identifier") def step_body_with_escaped_double_quotes(context): """Build a body containing a double-quoted identifier with "". E.g.: "col""name" TEXT, col2 INT """ context.split_body = '"col""name" TEXT, col2 INT' @when("I call split_entries on the body") def step_call_split_entries(context): """Call split_entries.""" context.split_result = split_entries(context.split_body) @then("the entries should be split correctly preserving the identifier") def step_verify_split_with_double_quotes(context): """Verify entries are split at depth-0 commas only.""" assert len(context.split_result) == 2, ( f"Expected 2 entries, got {len(context.split_result)}: {context.split_result}" ) assert '"col""name" TEXT' in context.split_result[0] assert "col2 INT" in context.split_result[1].strip() # --------------------------------------------------------------------------- # _skip_quoted: unclosed quote (line 197) # --------------------------------------------------------------------------- @given("SQL content with an unclosed single-quote inside parentheses") def step_content_unclosed_quote(context): """Build content with an unclosed single-quote. The _skip_quoted function should advance to end-of-string (return n). Because the quote is never closed, the parenthesis balance is broken, so extract_body returns "". """ context.unclosed_content = "(col1 TEXT DEFAULT 'unclosed" context.unclosed_start = 0 @when("I call extract_body on the unclosed-quote content") def step_call_extract_body_unclosed(context): """Call extract_body on content with unclosed quote.""" context.unclosed_result = extract_body( context.unclosed_content, context.unclosed_start ) @then("extract_body should return an empty string for unbalanced parens") def step_verify_unclosed_quote_empty(context): """When a quote is never closed the body can't be balanced.""" assert context.unclosed_result == "", ( f"Expected empty string, got {context.unclosed_result!r}" ) # --------------------------------------------------------------------------- # _skip_quoted: dollar-quoting (lines 198-203) # --------------------------------------------------------------------------- @given("SQL content with a dollar-quoted string inside parentheses") def step_content_dollar_quoted(context): """Build content with a $$...$$ dollar-quoted string. E.g.: (col1 TEXT DEFAULT $$hello, world$$, col2 INT) """ context.dollar_content = "(col1 TEXT DEFAULT $$hello, world$$, col2 INT)" context.dollar_start = 0 @when("I call extract_body on the dollar-quoted content") def step_call_extract_body_dollar(context): """Call extract_body.""" context.dollar_result = extract_body(context.dollar_content, context.dollar_start) @then("the body should be extracted correctly ignoring dollar-quoted content") def step_verify_dollar_body(context): """The body should contain both columns.""" assert "col1 TEXT" in context.dollar_result assert "col2 INT" in context.dollar_result @given("a table body containing a dollar-quoted string with commas") def step_dollar_body_with_commas(context): """A body where a dollar-quoted string contains commas that should be ignored.""" context.dollar_split_body = "col1 TEXT DEFAULT $$a,b,c$$, col2 INT" @when("I call split_entries on the dollar-quoted body") def step_call_split_entries_dollar(context): """Call split_entries.""" context.dollar_split_result = split_entries(context.dollar_split_body) @then("the comma inside the dollar-quoted string should not split entries") def step_verify_dollar_split(context): """Only 2 entries — the commas inside $$ should be ignored.""" assert len(context.dollar_split_result) == 2, ( f"Expected 2 entries, got {len(context.dollar_split_result)}: " f"{context.dollar_split_result}" ) @given("SQL content with an unclosed dollar-quoted string") def step_content_unclosed_dollar(context): """Build content with $$ that is never closed. This exercises the `else n` branch at line 203. """ context.unclosed_dollar_content = "(col1 TEXT DEFAULT $$unclosed body" context.unclosed_dollar_start = 0 @when("I call extract_body on the unclosed dollar-quote content") def step_call_extract_body_unclosed_dollar(context): """Call extract_body.""" context.unclosed_dollar_result = extract_body( context.unclosed_dollar_content, context.unclosed_dollar_start ) @then("extract_body should handle the unclosed dollar-quote gracefully") def step_verify_unclosed_dollar(context): """With unclosed dollar-quote, parens can't balance → empty string.""" assert context.unclosed_dollar_result == "", ( f"Expected empty string, got {context.unclosed_dollar_result!r}" ) # --------------------------------------------------------------------------- # _skip_quoted: lone dollar sign (line 204) # --------------------------------------------------------------------------- @given("SQL content with a lone dollar sign that is not a dollar-quote tag") def step_content_lone_dollar(context): """Build content with a $ that does not form a $$...$$ tag. A lone '$' at a position where _DOLLAR_RE.match fails exercises line 204 (return i + 1). We need the $ to appear in a position where it triggers the dollar-check but does not match the pattern $tag$. _DOLLAR_RE matches $tag$ patterns. A lone "$" followed by a non-word char (like a space) won't match. The check in extract_body is: ch == "$" and _DOLLAR_RE.match(content, i) If _DOLLAR_RE.match fails, the "and" short-circuits and the "$" is NOT sent to _skip_quoted. Line 204 is the fallback "return i+1" inside _skip_quoted when ch == "$" but the dollar-regex doesn't match at that position. We call _skip_quoted directly with a "$" where the pattern fails. """ # We'll test _skip_quoted directly for line 204 context.lone_dollar_text = "$ not a tag" context.lone_dollar_pos = 0 @when("I call extract_body on the lone-dollar content") def step_call_skip_quoted_lone_dollar(context): """Call _skip_quoted directly to exercise line 204.""" context.lone_dollar_result = _skip_quoted( context.lone_dollar_text, context.lone_dollar_pos ) @then("the body should be extracted correctly ignoring the lone dollar") def step_verify_lone_dollar(context): """_skip_quoted should return i+1 when $ doesn't form a dollar-quote.""" # For "$ not a tag" at position 0: # ch == "$", _DOLLAR_RE.match("$ not a tag", 0) tries to match \$(\w*)\$ # at position 0 — "$ " does not have a second $, so no match → return 0+1=1 assert context.lone_dollar_result == 1, ( f"Expected 1, got {context.lone_dollar_result}" ) # --------------------------------------------------------------------------- # extract_body: invalid paren_start (line 215) # --------------------------------------------------------------------------- @when("I call extract_body with paren_start beyond content length") def step_extract_body_beyond_length(context): """Call extract_body with paren_start >= len(content).""" context.beyond_result = extract_body("(abc)", 999) @then("extract_body should return an empty string for invalid start") def step_verify_empty_beyond(context): """Line 215 returns "" for out-of-range paren_start.""" assert context.beyond_result == "", ( f"Expected empty string, got {context.beyond_result!r}" ) @when("I call extract_body with paren_start pointing at a non-paren character") def step_extract_body_non_paren(context): """Call extract_body where content[paren_start] != '('.""" context.non_paren_result = extract_body("abc(def)", 0) @then("extract_body should return an empty string for non-paren start") def step_verify_empty_non_paren(context): """Line 215 returns "" when content[paren_start] is not '('.""" assert context.non_paren_result == "", ( f"Expected empty string, got {context.non_paren_result!r}" ) # --------------------------------------------------------------------------- # extract_body: unbalanced parens (line 230) # --------------------------------------------------------------------------- @given("SQL content with an opening paren but no closing paren") def step_content_unbalanced(context): """Content with an opening paren that is never closed.""" context.unbalanced_content = "(col1 TEXT, col2 INT" context.unbalanced_start = 0 @when("I call extract_body on the unbalanced content") def step_call_extract_body_unbalanced(context): """Call extract_body.""" context.unbalanced_result = extract_body( context.unbalanced_content, context.unbalanced_start ) @then("extract_body should return an empty string for missing close paren") def step_verify_unbalanced_empty(context): """Line 230: loop exits without finding close paren → return "".""" assert context.unbalanced_result == "", ( f"Expected empty string, got {context.unbalanced_result!r}" ) # --------------------------------------------------------------------------- # extract_fk_triples: column count mismatch (lines 383-388) # --------------------------------------------------------------------------- @given("a FOREIGN KEY clause with mismatched source and target column counts") def step_fk_mismatch(context): """Build a FOREIGN KEY clause where source has 2 cols but target has 1. FOREIGN KEY (col_a, col_b) REFERENCES other_table (col_x) """ fk_sql = "FOREIGN KEY (col_a, col_b) REFERENCES other_table (col_x)" m = FOREIGN_KEY_RE.search(fk_sql) assert m is not None, f"FOREIGN_KEY_RE did not match: {fk_sql!r}" context.fk_mismatch_match = m context.fk_resource_uri = "test-resource" context.fk_table_name = "my_table" context.fk_table_uri = table_uri("test-resource", "my_table") @when("I extract FK triples from the mismatched clause") def step_extract_fk_mismatch(context): """Call extract_fk_triples and capture log output.""" context.fk_log_records = [] class ListHandler(logging.Handler): def __init__(self, records_list): super().__init__() self._records = records_list def emit(self, record): self._records.append(record) _handler = ListHandler(context.fk_log_records) # Attach to the module's logger pg_logger = logging.getLogger("cleveragents.domain.models.acms._postgresql_helpers") # Re-enable the logger in case Alembic's fileConfig() disabled it pg_logger.disabled = False pg_logger.addHandler(_handler) pg_logger.setLevel(logging.DEBUG) try: context.fk_mismatch_triples = extract_fk_triples( context.fk_mismatch_match, context.fk_resource_uri, context.fk_table_name, context.fk_table_uri, ) finally: pg_logger.removeHandler(_handler) @then("a warning should be logged about the column count mismatch") def step_verify_fk_warning(context): """Lines 384-385, 387-388: warning about mismatched column counts.""" warnings = [r for r in context.fk_log_records if r.levelno == logging.WARNING] assert len(warnings) >= 1, ( f"Expected a WARNING log, got {len(warnings)} warnings; " f"all records: {[r.getMessage() for r in context.fk_log_records]}" ) msg = warnings[0].getMessage() assert "mismatch" in msg.lower(), f"Unexpected warning message: {msg}" @then("triples should be generated only for the shorter column list") def step_verify_fk_paired(context): """Only 1 foreignKeyTo triple (min of 2 source, 1 target).""" fk_to_triples = [ t for t in context.fk_mismatch_triples if t.predicate == "uko-data:foreignKeyTo" ] assert len(fk_to_triples) == 1, ( f"Expected 1 foreignKeyTo triple (min pairing), got {len(fk_to_triples)}" ) # --------------------------------------------------------------------------- # parse_table_body: empty entry (line 434) # --------------------------------------------------------------------------- @given("a table body string with empty entries between commas") def step_body_empty_entries(context): """A body like ' , col1 INT, , col2 TEXT, ' — has empty entries.""" context.empty_entries_body = " , col1 INT, , col2 TEXT, " context.empty_entries_resource = "test-resource" context.empty_entries_table = "my_table" context.empty_entries_table_uri = table_uri("test-resource", "my_table") @when("I call parse_table_body on the body with empty entries") def step_call_parse_table_body_empty(context): """Call parse_table_body.""" context.empty_triples = parse_table_body( context.empty_entries_body, context.empty_entries_resource, context.empty_entries_table, context.empty_entries_table_uri, ) @then("parse_table_body should produce triples only for valid column definitions") def step_verify_empty_entries_triples(context): """Only 2 real columns (col1, col2) should produce triples; empties are skipped.""" label_triples = [t for t in context.empty_triples if t.predicate == "rdfs:label"] assert len(label_triples) == 2, ( f"Expected 2 column labels, got {len(label_triples)}: " f"{[t.object_value for t in label_triples]}" ) names = {t.object_value for t in label_triples} assert names == {"col1", "col2"}, f"Unexpected column names: {names}" # --------------------------------------------------------------------------- # FK with quoted schema containing escaped double-quotes (line 50 via ident_pair) # --------------------------------------------------------------------------- @given("a FOREIGN KEY clause referencing a quoted schema with escaped double-quotes") def step_fk_quoted_schema(context): """Build: FOREIGN KEY (fk_col) REFERENCES "my""schema"."ref_table" (id)""" fk_sql = 'FOREIGN KEY (fk_col) REFERENCES "my""schema"."ref_table" (id)' m = FOREIGN_KEY_RE.search(fk_sql) assert m is not None, f"FOREIGN_KEY_RE did not match: {fk_sql!r}" context.fk_quoted_match = m context.fk_quoted_resource = "test-resource" context.fk_quoted_table = "source_table" context.fk_quoted_table_uri = table_uri("test-resource", "source_table") @when("I extract FK triples from the quoted-schema clause") def step_extract_fk_quoted_schema(context): """Call extract_fk_triples with the quoted-schema FK match.""" context.fk_quoted_triples = extract_fk_triples( context.fk_quoted_match, context.fk_quoted_resource, context.fk_quoted_table, context.fk_quoted_table_uri, ) @then("the triples should reference the correctly unescaped schema name") def step_verify_fk_quoted_schema_uri(context): """The target column URI should contain the unescaped schema name.""" fk_to_triples = [ t for t in context.fk_quoted_triples if t.predicate == "uko-data:foreignKeyTo" ] assert len(fk_to_triples) == 1 target_uri = fk_to_triples[0].object_uri # The schema "my""schema" should be unescaped to my"schema, # then safe_name converts " to _, so it becomes my_schema assert "my_schema" in target_uri, f"Expected 'my_schema' in URI, got {target_uri!r}"