Skip to content

Feature: Reworked record combiner to support redefines in the writer - #869

Open
Il-Pela wants to merge 1 commit into
AbsaOSS:masterfrom
Il-Pela:feature/rework-record-combiner-to-support-redefines
Open

Feature: Reworked record combiner to support redefines in the writer#869
Il-Pela wants to merge 1 commit into
AbsaOSS:masterfrom
Il-Pela:feature/rework-record-combiner-to-support-redefines

Conversation

@Il-Pela

@Il-Pela Il-Pela commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR adds REDEFINES support to NestedRecordCombiner. Previously, the writer silently dropped any field declared with REDEFINES when building its internal AST (stmt.redefines.isEmpty filter), meaning DataFrames could only ever populate the base field of a redefined region — any attempt to write via a redefining field was ignored or failed under strict schema validation.

What changed

  • WriterAst.scala

    • Added RedefineAlternative(fieldName, ast) and RedefineGroup(alternatives, actualSize) AST node types to represent a set of mutually-exclusive fields sharing the same byte region.
  • NestedRecordCombiner.scala

    • buildGroupField now clusters a base field together with its consecutive chain of REDEFINES fields into a single RedefineGroup node (mirroring the clustering/sizing already done in cobol-parser's BinaryPropertiesAdder), instead of dropping the redefining fields.
    • New buildRedefineGroup/buildChildNode helpers build each alternative (primitive or nested group) leniently, then apply strict-schema validation once at the cluster level — failing only if none of the alternatives are present in the DataFrame schema.
    • writeToBytes gained a RedefineGroup case:
      • Writes the bytes of the single populated alternative (chosen per-row based on which field is non-null).
      • Leaves the shared region as zero-bytes if no alternative is populated (or throws, under strict schema).
      • Fails fast with a clear IllegalArgumentException naming the conflicting fields if more than one alternative is populated on the same row.
    • New isPopulated helper determines whether an AST node (primitive, group, or nested RedefineGroup) has a value for a given row.

Behavior notes / design decisions

  • Conflict policy is fail-fast: writing a row where two alternatives of the same REDEFINES chain are both non-null throws immediately, rather than silently picking one.
  • Byte width follows the widest alternative: the shared region always reserves the largest alternative's size (consistent with how the parser computes offsets), so a 5-byte base field redefined by a 35-byte field still reserves 35 bytes; unused trailing bytes are binary zeroes (0x00), not spaces.
  • REDEFINES works on group fields too: an alternative can itself be a nested group with its own sub-fields, not just a primitive.
  • No changes to cobol-parser, RecordCombinerSelector, or reading/decoding logic — this is writer-only and fully backward compatible with non-REDEFINES copybooks.

Testing

Added 10 new tests to FixedLengthEbcdicWriterSuite:

  1. Write using only the base field (regression baseline).
  2. Write using only the redefining field.
  3. Fail fast when base + redefine are both populated.
  4. Write the 3rd alternative of a 3-way REDEFINES chain.
  5. Fail fast on conflict between non-adjacent alternatives in a 3-way chain.
  6. Mixed rows in a single DataFrame (2 rows via base field, 2 via redefining field) — validates per-row dispatch.
  7. Zero-filled bytes when no alternative is present and strict_schema=false.
  8. Clear error when no alternative is present and strict_schema=true (default).
  9. Alternatives of different sizes — validates max-size cluster width and zero-byte padding.
  10. REDEFINES on nested group fields, including a REC-TYPE discriminator column following the common COBOL convention for tagging which alternative a record uses.

Files changed

  • spark-cobol/src/main/scala/za/co/absa/cobrix/spark/cobol/writer/NestedRecordCombiner.scala
  • spark-cobol/src/main/scala/za/co/absa/cobrix/spark/cobol/writer/WriterAst.scala
  • spark-cobol/src/test/scala/za/co/absa/cobrix/spark/cobol/writer/FixedLengthEbcdicWriterSuite.scala

Final Notes

Please let me know what do you think about this PR and if there is the margin of adding this functionality to the library. I'm open to further communication and collaboration and looking forward to read feedbacks from you.

Co-author of this PR: Andrea Fonti

Thanks again for the immense work you're doing into maintaining this project.

Talk soon,
Francesco

Summary by CodeRabbit

Release Notes

  • Improvements

    • Enhanced handling of COBOL REDEFINES alternatives with better validation and error reporting.
    • Improved data serialization logic to correctly process mutually exclusive field alternatives.
  • Tests

    • Added comprehensive test coverage for REDEFINES group scenarios, including edge cases and conflict detection.

@Il-Pela
Il-Pela requested a review from yruslan as a code owner August 4, 2026 16:47
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR adds REDEFINES group support to the spark-cobol writer. New RedefineAlternative and RedefineGroup AST nodes model mutually exclusive fields. The combiner clusters base and redefining statements, serializes exactly one populated alternative, and validates conflicts. Tests cover the new behavior.

Changes

REDEFINES writer support

Layer / File(s) Summary
RedefineGroup and RedefineAlternative AST nodes
spark-cobol/src/main/scala/za/co/absa/cobrix/spark/cobol/writer/WriterAst.scala
Adds RedefineAlternative(fieldName, ast) and RedefineGroup(alternatives, actualSize) case classes with documentation of mutually exclusive alternative behavior.
AST construction with REDEFINES clustering
spark-cobol/src/main/scala/za/co/absa/cobrix/spark/cobol/writer/NestedRecordCombiner.scala
Imports Statement type. Clusters each base statement with its consecutive REDEFINES alternatives in declaration order, and raises strict errors or filler warnings when no alternative is present.
Serialization of mutually exclusive alternatives
spark-cobol/src/main/scala/za/co/absa/cobrix/spark/cobol/writer/NestedRecordCombiner.scala
Writes the sole populated alternative, writes zero bytes when none is populated, and throws an error when multiple alternatives are populated. Adds recursive isPopulated checks across all writer AST node types.
Writer test coverage for REDEFINES scenarios
spark-cobol/src/test/scala/za/co/absa/cobrix/spark/cobol/writer/FixedLengthEbcdicWriterSuite.scala
Adds tests for base/alternate fields, mixed rows, three-way and nested groups, conflicts, missing alternatives, strict-schema behavior, differing widths, and zero-padding. Adds readPartFileBytes and causeChainMessages helpers.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Row as Input Row
  participant Combiner as NestedRecordCombiner
  participant Group as RedefineGroup
  participant Output as Output Bytes

  Row->>Combiner: build writer AST with RedefineGroup
  Combiner->>Group: check each RedefineAlternative for populated data
  alt exactly one alternative populated
    Group->>Output: write bytes for that alternative
  else no alternative populated
    Group->>Output: write zero-filled bytes
  else multiple alternatives populated
    Group->>Combiner: throw conflict error
  end
Loading

Possibly related PRs

  • AbsaOSS/cobrix#775: Extends the earlier FixedLengthEbcdicWriterSuite and writer AST/combiner functionality to support COBOL REDEFINES groups.
  • AbsaOSS/cobrix#829: Overlaps in NestedRecordCombiner.scala and WriterAst.scala, extending writer AST and serialization logic with REDEFINES grouping.
  • AbsaOSS/cobrix#834: Builds on strict-schema changes in NestedRecordCombiner.scala, extending missing-field handling for REDEFINES alternatives.

Suggested reviewers: yruslan

Poem

A rabbit hops through COBOL fields,
Where REDEFINES their secrets yield.
One alternative wins the race,
Zero bytes fill empty space.
Conflicts caught, no bytes misplaced —
🐇 Hop, hop, the write is graced!

🚥 Pre-merge checks | ✅ 5
✅ 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 describes the main change: reworking the record combiner to support COBOL REDEFINES in the writer.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
spark-cobol/src/main/scala/za/co/absa/cobrix/spark/cobol/writer/WriterAst.scala (1)

78-81: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Correct the actualSize description.

The doc says the shared region size is "uniform across all alternatives". Alternatives can have different sizes. The differing-size test in FixedLengthEbcdicWriterSuite.scala (lines 720-753) expects the region to span the widest alternative. Describe actualSize as the size of the shared region, which covers the widest alternative.

📝 Proposed documentation fix
     * `@param` alternatives The list of mutually exclusive alternatives sharing the byte region.
-    * `@param` actualSize   The size, in bytes, of the shared byte region (uniform across all alternatives).
+    * `@param` actualSize   The size, in bytes, of the shared byte region. It spans the widest
+    *                     alternative; narrower alternatives leave the trailing bytes as zeroes.
     */
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@spark-cobol/src/main/scala/za/co/absa/cobrix/spark/cobol/writer/WriterAst.scala`
around lines 78 - 81, Update the RedefineGroup actualSize Scaladoc to describe
it as the size of the shared byte region covering the widest alternative, rather
than implying all alternatives have uniform sizes.
spark-cobol/src/main/scala/za/co/absa/cobrix/spark/cobol/writer/NestedRecordCombiner.scala (1)

566-574: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider a deep population check for group alternatives.

isPopulated treats a GroupField as populated when the nested Row is not null. A row can contain a non-null struct whose fields are all null. Two alternatives can then both look populated, and the write fails with the conflict error even though no value exists.

A recursive check over children would make the decision match the actual data:

♻️ Proposed deep check for group nodes
-    case GroupField(_, _, getter)        => getter(row) != null
+    case GroupField(children, _, getter) =>
+      val nestedRow = getter(row)
+      nestedRow != null && children.exists(child => isPopulated(child, nestedRow))

Confirm the intended semantics before applying this change. Spark JSON sources usually produce a null struct for an absent group, so the current check is sufficient for the added tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@spark-cobol/src/main/scala/za/co/absa/cobrix/spark/cobol/writer/NestedRecordCombiner.scala`
around lines 566 - 574, Confirm the intended population semantics before
changing isPopulated: Spark JSON inputs typically represent absent groups as
null structs, so retain the current non-null GroupField and GroupArray checks
unless the added tests require distinguishing empty nested Rows. Do not
introduce a recursive child-value check without validation, while preserving
RedefineGroup alternative conflict behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@spark-cobol/src/test/scala/za/co/absa/cobrix/spark/cobol/writer/FixedLengthEbcdicWriterSuite.scala`:
- Around line 574-576: Strengthen both REDEFINES conflict assertions in
FixedLengthEbcdicWriterSuite: at
spark-cobol/src/test/scala/za/co/absa/cobrix/spark/cobol/writer/FixedLengthEbcdicWriterSuite.scala:574-576,
require m.contains("'B', 'B1'"); at
spark-cobol/src/test/scala/za/co/absa/cobrix/spark/cobol/writer/FixedLengthEbcdicWriterSuite.scala:668-670,
require m.contains("'B', 'B2'") instead of separate substring checks.

---

Nitpick comments:
In
`@spark-cobol/src/main/scala/za/co/absa/cobrix/spark/cobol/writer/NestedRecordCombiner.scala`:
- Around line 566-574: Confirm the intended population semantics before changing
isPopulated: Spark JSON inputs typically represent absent groups as null
structs, so retain the current non-null GroupField and GroupArray checks unless
the added tests require distinguishing empty nested Rows. Do not introduce a
recursive child-value check without validation, while preserving RedefineGroup
alternative conflict behavior.

In
`@spark-cobol/src/main/scala/za/co/absa/cobrix/spark/cobol/writer/WriterAst.scala`:
- Around line 78-81: Update the RedefineGroup actualSize Scaladoc to describe it
as the size of the shared byte region covering the widest alternative, rather
than implying all alternatives have uniform sizes.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d7473955-71db-4ddb-aaf2-0c94cc2ed711

📥 Commits

Reviewing files that changed from the base of the PR and between 40433de and 4f48d8d.

📒 Files selected for processing (3)
  • spark-cobol/src/main/scala/za/co/absa/cobrix/spark/cobol/writer/NestedRecordCombiner.scala
  • spark-cobol/src/main/scala/za/co/absa/cobrix/spark/cobol/writer/WriterAst.scala
  • spark-cobol/src/test/scala/za/co/absa/cobrix/spark/cobol/writer/FixedLengthEbcdicWriterSuite.scala

Comment on lines +574 to +576
val messages = causeChainMessages(thrown)
assert(messages.exists(m => m.contains("B") && m.contains("B1")),
s"Expected an error mentioning both conflicting REDEFINES fields 'B' and 'B1', but got: ${messages.mkString(" | ")}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Both REDEFINES conflict assertions accept a message that names only one field. The shared root cause is a substring predicate: "B1" and "B2" both contain "B", so m.contains("B") adds no verification. Assert the exact conflicting-name list that the writer produces.

  • spark-cobol/src/test/scala/za/co/absa/cobrix/spark/cobol/writer/FixedLengthEbcdicWriterSuite.scala#L574-L576: replace the two contains checks with m.contains("'B', 'B1'").
  • spark-cobol/src/test/scala/za/co/absa/cobrix/spark/cobol/writer/FixedLengthEbcdicWriterSuite.scala#L668-L670: replace the two contains checks with m.contains("'B', 'B2'").
📍 Affects 1 file
  • spark-cobol/src/test/scala/za/co/absa/cobrix/spark/cobol/writer/FixedLengthEbcdicWriterSuite.scala#L574-L576 (this comment)
  • spark-cobol/src/test/scala/za/co/absa/cobrix/spark/cobol/writer/FixedLengthEbcdicWriterSuite.scala#L668-L670
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@spark-cobol/src/test/scala/za/co/absa/cobrix/spark/cobol/writer/FixedLengthEbcdicWriterSuite.scala`
around lines 574 - 576, Strengthen both REDEFINES conflict assertions in
FixedLengthEbcdicWriterSuite: at
spark-cobol/src/test/scala/za/co/absa/cobrix/spark/cobol/writer/FixedLengthEbcdicWriterSuite.scala:574-576,
require m.contains("'B', 'B1'"); at
spark-cobol/src/test/scala/za/co/absa/cobrix/spark/cobol/writer/FixedLengthEbcdicWriterSuite.scala:668-670,
require m.contains("'B', 'B2'") instead of separate substring checks.

@yruslan yruslan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is amazing! The solution is very elegant and solves the very important use case. I like it a lot. Have just 1 suggestion to consider.

Comment on lines +552 to +557
case multiple =>
val fieldNames = multiple.map(_.fieldName).mkString("', '")
throw new IllegalArgumentException(
s"Conflicting REDEFINES fields populated on the same row: '$fieldNames'. " +
s"Only one field of a REDEFINES group can have a non-null value at a time."
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Throwing exceptions from inside a Spark job is not a usual practice since this can cancel a job that processes GBs of data just on a single data error. Usually, in Spark throwing exception on data is the last resort.

I'd prefer when multiple alternatives are possible, just use the first one.

No need to fix it yourself, I can fix the logic once the PR is merged. Up to you.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep, I totally agree with you.
I can work on this between today and tomorrow and update the PR by implementing your suggestion (use the first one when there there are multiple alternatives).

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.

2 participants