Skip to content

Implement PostgreSQL COPY escape sequence handling (fixes #7) - #17

Merged
gmr merged 2 commits into
mainfrom
fix-issue-7-escape-sequences
Nov 19, 2025
Merged

gmr merged 2 commits into
mainfrom
fix-issue-7-escape-sequences

Conversation

@gmr

@gmr gmr commented Nov 19, 2025 •

Copy link
Copy Markdown
Owner

Summary

This PR implements full support for PostgreSQL COPY text format escape sequences, resolving issue #7. The implementation is based directly on PostgreSQL's canonical source code.

Changes

Core Implementation

  • Added unescape_copy_text() function that handles all PostgreSQL COPY escape sequences:
    • \b, \f, \n, \r, \t, \v - single character escapes
    • \NNN - octal byte values (1-3 digits)
    • \xNN - hex byte values (1-2 digits)
    • \\ - backslash
    • \X - any other character taken literally

Converter Updates

  • Updated DataConverter to unescape data after splitting by tabs
  • Updated SmartDataConverter to unescape before type conversion
  • NULL marker (\N) is checked before unescaping (matching PostgreSQL behavior)

Testing

Added comprehensive test suite with 16 new test cases:

  • All escape sequence types (backslash, newline, tab, etc.)
  • Octal and hex escape sequences
  • Edge cases (multiple digits, case sensitivity)
  • NULL marker preservation
  • Integration with existing converters

Documentation

  • Updated module docstring to document supported escape sequences
  • Updated class docstrings to reflect new behavior
  • Added detailed function documentation

Implementation Details

The implementation is a direct port of PostgreSQL's escape sequence handling from:

build/postgres/src/backend/commands/copyfromparse.c
Lines 1657-1749: CopyReadAttributesText() function

This ensures 100% compatibility with how PostgreSQL itself handles escape sequences in COPY text format.

Testing

All 121 tests pass, including:

  • All existing tests (no regressions)
  • 16 new escape sequence tests
  • Tests for backward compatibility (NULL handling)

Breaking Changes

None - this is purely additive functionality. Previously, escape sequences were returned as literal strings (e.g., "\\n" as two characters). Now they are properly unescaped to their actual values (e.g., "\n" as a newline character).

This matches PostgreSQL's behavior and is the expected behavior for COPY text format.

Resolves #7


🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Corrected handling of PostgreSQL COPY text escape sequences (octal, hex, and standard escapes) so escaped characters are interpreted correctly and NULL markers are preserved.
  • New Features

    • Data converters now perform explicit unescaping of COPY-format sequences before type conversion, improving accuracy for numeric, UUID, and IP address fields.
  • Tests

    • Added comprehensive tests covering all COPY escape variants, edge cases, and end-to-end converter behavior.

✏️ Tip: You can customize this high-level summary in your review settings.

This commit implements full support for PostgreSQL COPY text format
escape sequences, matching the behavior of PostgreSQL's
CopyReadAttributesText() function in copyfromparse.c.

Changes:
- Added unescape_copy_text() function that handles all PostgreSQL
  escape sequences:
  * \\b, \\f, \\n, \\r, \\t, \\v - single character escapes
  * \\NNN - octal byte values (1-3 digits)
  * \\xNN - hex byte values (1-2 digits)
  * \\\\ - backslash
  * \\X - any other character taken literally

- Updated DataConverter and SmartDataConverter to unescape data
  after splitting by tabs but before type conversion

- NULL marker (\\N) is checked before unescaping, matching
  PostgreSQL's behavior

- Added comprehensive test suite with 16 new test cases covering:
  * All escape sequence types
  * Edge cases (octal/hex digit combinations)
  * Integration with existing converters
  * NULL marker preservation

- Updated module documentation to document escape sequence support

This resolves issue #7 where backslash sequences other than \\N
were not being interpreted correctly.

Implementation is based directly on PostgreSQL source code at:
build/postgres/src/backend/commands/copyfromparse.c lines 1657-1749

Resolves #7
@coderabbitai

coderabbitai Bot commented Nov 19, 2025 •

Copy link
Copy Markdown

Walkthrough

Adds an unescape_copy_text(field: str) utility to decode PostgreSQL COPY text escapes and updates DataConverter and SmartDataConverter to unescape fields before type conversion while preserving \N as a NULL marker. Tests covering many escape cases are added.

Changes

Cohort / File(s) Summary
Escape sequence handler
pgdumplib/converters.py
Added unescape_copy_text(field: str) to decode COPY text escapes (octal \NNN, hex \xNN, and standard escapes \b, \f, \n, \r, \t, \v, \\; unknown \X treated literally). DataConverter.convert() now unescapes fields and maps \N to None. SmartDataConverter._convert_column() checks for NULL first, then unescapes prior to numeric/UUID/IP conversions; docstrings updated.
Test coverage
tests/test_converters.py
Added UnescapeCopyTextTestCase with tests for: no escapes, backslash, newline, carriage return, tab, backspace, form feed, vertical tab, octal escapes, hex escapes, literal/unknown escapes, incomplete hex, trailing backslash, combined escapes, NULL marker handling, and integration tests verifying DataConverter and SmartDataConverter behavior.

Sequence Diagram(s)

sequenceDiagram
    participant Raw as Raw Field Data
    participant Conv as DataConverter / SmartDataConverter
    participant Unesc as unescape_copy_text()
    participant Result as Output

    Raw->>Conv: receive field (string)
    Conv->>Conv: check if field == "\\N"
    alt field is "\N"
        Conv-->>Result: return None
    else
        Conv->>Unesc: unescape_copy_text(field)
        Note over Unesc: handle \b \f \n \r \t \v \\ \xNN \NNN\n(unknown \X left literal)
        Unesc-->>Conv: unescaped string
        Conv->>Conv: (SmartDataConverter) perform type conversions on unescaped string
        Conv-->>Result: return converted/unescaped value
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Pay attention to octal (\NNN) and hex (\xNN) parsing edge cases and bounds.
  • Verify that \N is preserved as NULL only when exactly \N.
  • Confirm unescape runs before type inference/conversion in SmartDataConverter.
  • Review test coverage for malformed/partial escapes and trailing backslashes.

Poem

🐰 Through tangled backslashes I hop and comb,

Octal hops and hex lead data home.
I unescape the maze with a joyful cheer,
\N stays null — the meaning's clear. 🎉

Pre-merge checks and finishing touches

✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: implementing PostgreSQL COPY escape sequence handling, with direct reference to the resolved issue.
Linked Issues check ✅ Passed The PR fully implements the requirements from issue #7 by adding unescape_copy_text() function supporting all required escape types (single-char, octal, hex, backslash) and integrating it into both DataConverter and SmartDataConverter with proper NULL marker handling.
Out of Scope Changes check ✅ Passed All changes are directly related to issue #7 objectives: core unescape logic, converter integration, comprehensive testing, and documentation updates with no unrelated modifications.
Docstring Coverage ✅ Passed Docstring coverage is 95.45% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix-issue-7-escape-sequences

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
pgdumplib/converters.py (1)

40-124: unescape_copy_text matches PostgreSQL COPY semantics; drop unused noqa and consider a couple more edge-case tests

The implementation correctly handles:

  • Single-char escapes (\b, \f, \n, \r, \t, \v).
  • Octal escapes with 1–3 digits, masking to a single byte.
  • Hex escapes with 1–2 digits, consuming only valid hex chars and leaving the rest literal.
  • \\ → \ and generic \X → X behavior via the final else branch.
  • Early return when no backslash is present, and safe handling of a trailing backslash.

Behavior for \\N intentionally yields 'N', with call sites checking for \\N before unescaping, which matches PostgreSQL’s CopyReadAttributesText() usage pattern.

Static analysis notes that the # noqa: C901 on Line 40 is unused. Since this function is well within reasonable complexity, it’s safe to remove the directive.

Apply this diff to satisfy Ruff:

-def unescape_copy_text(field: str) -> str:  # noqa: C901
+def unescape_copy_text(field: str) -> str:

Optionally, you could add a couple of micro edge-case tests (e.g., \\x with no digits and a trailing backslash) to lock in current behavior:

  • unescape_copy_text('\\x') -> 'x'
  • unescape_copy_text('\\xG') -> 'xG'
  • unescape_copy_text('foo\\') -> 'foo\\'
tests/test_converters.py (1)

80-240: New escape-sequence tests are thorough and aligned with the implementation; a couple of optional edge cases could be added

The UnescapeCopyTextTestCase suite does a good job of pinning down behavior:

  • All advertised escapes (\\b, \\f, \\n, \\r, \\t, \\v, octal, hex, \\\\, and literal \\X) are exercised.
  • Octal/hex expectations (e.g., \\101 -> 'A', \\377 -> '\xff', \\x41 -> 'A') match the implemented parsing.
  • test_null_marker_not_unescaped correctly documents and verifies that unescape_copy_text('\\N') == 'N', with converter tests ensuring \\N is special-cased before unescaping.
  • Converter integration tests confirm DataConverter and SmartDataConverter both unescape content while preserving NULL markers and type inference.

Everything here looks consistent with the new core logic.

As a small optional enhancement, you could add tests for the edge cases that the implementation already handles:

  • self.assertEqual(converters.unescape_copy_text('\\x'), 'x')
  • self.assertEqual(converters.unescape_copy_text('\\xG'), 'xG')
  • self.assertEqual(converters.unescape_copy_text('foo\\'), 'foo\\')

to make the intended behavior around incomplete hex escapes and trailing backslashes explicit.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 96a9f80 and 7a5aff5.

📒 Files selected for processing (2)
  • pgdumplib/converters.py (4 hunks)
  • tests/test_converters.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
pgdumplib/converters.py (1)
pgdumplib/dump.py (1)
  • append (65-78)
tests/test_converters.py (1)
pgdumplib/converters.py (6)
  • unescape_copy_text (40-124)
  • DataConverter (127-150)
  • convert (140-150)
  • convert (157-164)
  • convert (203-207)
  • SmartDataConverter (181-246)
🪛 Ruff (0.14.5)
pgdumplib/converters.py

40-40: Unused noqa directive (non-enabled: C901)

Remove unused noqa directive

(RUF100)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (28)
  • GitHub Check: test (3.14, 17)
  • GitHub Check: test (3.11, 16)
  • GitHub Check: test (3.14, 18)
  • GitHub Check: test (3.14, 15)
  • GitHub Check: test (3.13, 18)
  • GitHub Check: test (3.12, 16)
  • GitHub Check: test (3.13, 16)
  • GitHub Check: test (3.13, 17)
  • GitHub Check: test (3.14, 16)
  • GitHub Check: test (3.13, 15)
  • GitHub Check: test (3.12, 18)
  • GitHub Check: test (3.11, 17)
  • GitHub Check: test (3.12, 15)
  • GitHub Check: test (3.12, 17)
  • GitHub Check: test (3.11, 15)
  • GitHub Check: test (3.11, 18)
  • GitHub Check: test (3.13, 18)
  • GitHub Check: test (3.12, 18)
  • GitHub Check: test (3.13, 17)
  • GitHub Check: test (3.13, 15)
  • GitHub Check: test (3.13, 16)
  • GitHub Check: test (3.12, 15)
  • GitHub Check: test (3.12, 16)
  • GitHub Check: test (3.11, 18)
  • GitHub Check: test (3.12, 17)
  • GitHub Check: test (3.11, 16)
  • GitHub Check: test (3.11, 15)
  • GitHub Check: test (3.11, 17)
🔇 Additional comments (3)
pgdumplib/converters.py (3)

6-24: Module-level documentation accurately describes new COPY escape behavior

The description of DataConverter/SmartDataConverter and the supported COPY text escape sequences aligns with the implementation, including NULL (\\N) handling and unescaping order. No changes needed here.


133-150: DataConverter: correct ordering of NULL check vs unescaping

The comprehension now:

  • Splits on actual tabs (matching COPY text format).
  • Checks e == '\\N' before calling unescape_copy_text, so the NULL marker is converted to None and never misinterpreted as 'N'.
  • Unescapes all other fields, giving callers the fully decoded COPY payload.

This is the right behavior and aligns with the new tests and module docs.


210-246: SmartDataConverter: NULL-first logic and unescape-before-type-detection look solid

_convert_column now:

  • Detects column == '\\N' up front and returns None, preserving PostgreSQL’s NULL semantics.
  • Unescapes the remaining values before running numeric/Decimal/IP/UUID/datetime detection, ensuring type inference sees the true content (e.g., '42' rather than '4\\2', 'hello\nworld' instead of 'hello\\nworld').

This preserves existing behavior for bad dates and other non-matching values while improving correctness for escaped content. No changes needed.

Per CodeRabbit feedback, add tests for:
- Incomplete hex escapes (\x, \xG, \xZ9)
- Trailing backslashes (foo\, \)

The noqa directive is needed for local pre-commit hooks (C901
enabled with threshold 15, function complexity 18). The complexity
is inherent to matching PostgreSQL's implementation.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
tests/test_converters.py (1)

135-171: Octal and hex escape sequences well-tested.

The tests comprehensively cover all digit ranges and verify correct numeric conversions. Case sensitivity for hex is properly tested.

Optional: Consider adding a test for invalid octal digits to demonstrate edge-case handling:

def test_invalid_octal_escapes(self):
    """Test that non-octal digits after backslash are literal"""
    self.assertEqual(converters.unescape_copy_text('\\8'), '8')
    self.assertEqual(converters.unescape_copy_text('\\9'), '9')
    # Octal stops at non-octal digit
    self.assertEqual(converters.unescape_copy_text('\\078'), '\x078')
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7a5aff5 and 97b4012.

📒 Files selected for processing (1)
  • tests/test_converters.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
tests/test_converters.py (1)
pgdumplib/converters.py (6)
  • unescape_copy_text (40-124)
  • DataConverter (127-150)
  • convert (140-150)
  • convert (157-164)
  • convert (203-207)
  • SmartDataConverter (181-246)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (29)
  • GitHub Check: test (3.14, 18)
  • GitHub Check: test (3.14, 15)
  • GitHub Check: test (3.13, 18)
  • GitHub Check: test (3.14, 17)
  • GitHub Check: test (3.13, 15)
  • GitHub Check: test (3.13, 17)
  • GitHub Check: test (3.12, 17)
  • GitHub Check: test (3.12, 16)
  • GitHub Check: test (3.13, 16)
  • GitHub Check: test (3.11, 18)
  • GitHub Check: test (3.12, 18)
  • GitHub Check: test (3.11, 15)
  • GitHub Check: test (3.11, 17)
  • GitHub Check: test (3.12, 15)
  • GitHub Check: test (3.11, 16)
  • GitHub Check: test (3.12, 17)
  • GitHub Check: test (3.14, 16)
  • GitHub Check: test (3.13, 17)
  • GitHub Check: test (3.14, 17)
  • GitHub Check: test (3.13, 18)
  • GitHub Check: test (3.12, 18)
  • GitHub Check: test (3.12, 15)
  • GitHub Check: test (3.13, 16)
  • GitHub Check: test (3.11, 15)
  • GitHub Check: test (3.13, 15)
  • GitHub Check: test (3.12, 16)
  • GitHub Check: test (3.11, 16)
  • GitHub Check: test (3.11, 17)
  • GitHub Check: test (3.11, 18)
🔇 Additional comments (6)
tests/test_converters.py (6)

80-86: Excellent test class documentation.

The docstring clearly establishes the purpose and scope of these tests, explicitly referencing PostgreSQL's behavior. This provides valuable context for future maintainers.


88-133: Comprehensive coverage of single-character escape sequences.

All documented single-character escape sequences are tested thoroughly, including edge cases with multiple occurrences and embedded contexts.


173-190: Excellent edge case coverage.

The tests correctly verify PostgreSQL's behavior for:

  • Unknown escape sequences (taken literally without the backslash)
  • Incomplete hex escapes (falling back to literal 'x')
  • Trailing backslashes (preserved as-is)

These edge cases align perfectly with the implementation logic.


192-212: Critical test: NULL marker handling is correct.

The test_null_marker_not_unescaped test correctly verifies that unescape_copy_text('\\N') returns 'N', not None. This is the proper design because converters must check for the NULL marker BEFORE unescaping, matching PostgreSQL's CopyReadAttributesText() behavior.

The combined escapes test ensures state is properly maintained across sequential escape sequences.


214-253: Robust integration testing with converters.

These tests verify the critical interaction between unescaping and converter logic:

  1. NULL marker precedence: Both converters correctly check for \N BEFORE unescaping (lines 220-221, 252)
  2. Escape-separator interaction: Line 238-241 excellently tests that escaped tabs within field data (\t) don't interfere with actual tab field separators
  3. Type conversion order: Line 251 confirms SmartDataConverter unescapes before type detection, ensuring escape sequences don't break type inference

80-253: Outstanding test coverage for PostgreSQL COPY escape sequences.

This test suite demonstrates excellent software engineering:

  • Comprehensive: All documented escape sequences plus edge cases
  • Well-organized: Logical grouping from basic to complex scenarios
  • Integration-focused: Tests both the function and its use in converters
  • PostgreSQL-aligned: Explicitly matches PostgreSQL's behavior, including the critical NULL-marker-before-unescaping sequence

The 16 new tests bring the total to 121, all passing. This is production-ready code.

@gmr
gmr merged commit 66b6084 into main Nov 19, 2025
33 checks passed
@gmr
gmr deleted the fix-issue-7-escape-sequences branch November 19, 2025 22:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Interpret backslash sequences

1 participant