Skip to content

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

Merged
yruslan merged 2 commits into
AbsaOSS:masterfrom
Il-Pela:feature/rework-record-combiner-to-support-redefines
Aug 6, 2026
Merged

Feature: Reworked record combiner to support redefines in the writer#869
yruslan merged 2 commits 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).
      • When more than one alternative is populated on the same row, the behavior is now configurable via the new write_strict_redefines option (default false):
      • Non-strict (default): writes the first populated alternative in copybook declaration order (base field before its redefiners), logs a warning naming the ignored fields, and continues the write.
      • Strict (write_strict_redefines=true): fails fast with a clear IllegalArgumentException naming the conflicting fields, as before.
    • 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 now configurable instead of always fail-fast: by default, writing a row where two alternatives of the same REDEFINES chain are both non-null writes the first populated alternative (in copybook order) and warns, so a single malformed row no longer aborts the whole write job. Opt into the previous fail-fast behavior with write_strict_redefines=true.
  • 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 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 and write_strict_redefines=true.
  4. Write the first alternative (base field) when base + redefine are both populated and strict mode is disabled (default).
  5. Write the 3rd alternative of a 3-way REDEFINES chain.
  6. Fail fast on conflict between non-adjacent alternatives in a 3-way chain, with write_strict_redefines=true.
  7. Write the first alternative of a 3-way chain when non-adjacent alternatives conflict and strict mode is disabled (default).
  8. Mixed rows in a single DataFrame (2 rows via base field, 2 via redefining field) — validates per-row dispatch.
  9. Zero-filled bytes when no alternative is present and strict_schema=false.
  10. Clear error when no alternative is present and strict_schema=true (default).
  11. Alternatives of different sizes — validates max-size cluster width and zero-byte padding.
  12. REDEFINES on nested group fields, including a REC-TYPE discriminator column following the common COBOL convention for tagging which alternative a record uses.

Also added a CobolParametersParserSuite case verifying write_strict_redefines parses into WriterParameters.strictRedefines.

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
  • cobol-parser/src/main/scala/za/co/absa/cobrix/cobol/reader/parameters/WriterParameters.scala
  • cobol-parser/src/main/scala/za/co/absa/cobrix/cobol/reader/parameters/CobolParametersParser.scala
  • cobol-parser/src/test/scala/za/co/absa/cobrix/cobol/reader/parameters/CobolParametersParserSuite.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 COBOL REDEFINES group support to the Spark writer. It adds AST nodes and strict configuration, groups alternatives during AST construction, serializes populated alternatives, and tests conflicts, missing fields, widths, padding, and nesting.

Changes

REDEFINES writer support

Layer / File(s) Summary
REDEFINES AST and strict configuration
spark-cobol/.../WriterAst.scala, cobol-parser/.../WriterParameters.scala, cobol-parser/.../CobolParametersParser.scala, cobol-parser/.../CobolParametersParserSuite.scala
Adds RedefineAlternative and RedefineGroup. Adds the strictRedefines writer option and parser coverage.
AST construction with REDEFINES clustering
spark-cobol/.../NestedRecordCombiner.scala
Groups base statements with consecutive alternatives and handles missing alternatives according to strict schema settings.
Serialization of mutually exclusive alternatives
spark-cobol/.../NestedRecordCombiner.scala
Serializes one populated alternative, leaves empty groups unchanged, handles conflicts, and detects populated nested AST nodes recursively.
REDEFINES writer validation
spark-cobol/.../FixedLengthEbcdicWriterSuite.scala
Tests alternative selection, conflicts, missing alternatives, strict behavior, differing widths, zero-padding, nested groups, output bytes, and diagnostic helpers.

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

Sequence Diagram(s)

sequenceDiagram
  participant InputRow
  participant NestedRecordCombiner
  participant RedefineGroup
  participant OutputBytes

  InputRow->>NestedRecordCombiner: Provide field values
  NestedRecordCombiner->>RedefineGroup: Inspect alternatives
  RedefineGroup->>RedefineGroup: Detect populated alternatives
  alt One alternative populated
    RedefineGroup->>OutputBytes: Serialize selected alternative
  else No alternative populated
    RedefineGroup->>OutputBytes: Write empty shared bytes
  else Multiple alternatives populated
    RedefineGroup->>NestedRecordCombiner: Report conflict or select first alternative
  end
Loading

Possibly related PRs

  • AbsaOSS/cobrix#775: Introduces the writer AST and test structures extended by this change.
  • AbsaOSS/cobrix#829: Changes the same writer AST and combiner architecture used for REDEFINES support.

Suggested reviewers: yruslan

Poem

A rabbit groups the fields just right,
One choice is written in the byte.
Empty groups keep their space,
Conflicts raise a clear red flag.
Nested forms now pass the test.
🐇 Hops onward through the record nest.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
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.
✨ 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).

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.

Ciao @yruslan , I've updated the PR with your suggestion.
I modeled it with a new writer option that

  • by default is non-strict: when multiple alternatives are possible, takes the first one.
  • eventually can be made strict: fails when multiple alternatives are found.

I did that because I agreed with you that from an analytics point of view throwing GBs of data for an error on a single row is not optimal.
However, from an operational point of view, an aware user, could opt for a stricter management of such scenarios to fail the job to avoid unwanted writes during the process that can potentially harm downstream operations.

I've updated the PR text/references accordingly.

Let me know what you think about this.

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.

Perfect, thank you! Merging...

One additional use case came to mind in regards to redefines. In come copybooks we have redefines that provide multiple 'views' on the same field, e.g.:

05  ACCOUNT-NUMBER-FULL  9(10).
05  ACCOUNT-NUMBER-DETAIL  REDEFINES ACCOUNT-NUMBER-FULL.
    10 PREFIX        9(4).
    10 NUMBER        9(6).

In this case both alternatives would have values, but they are essentially the same. So having the relaxed redefine strictness by default makes perfect sense.

@yruslan
yruslan merged commit 97ef938 into AbsaOSS:master Aug 6, 2026
6 checks passed
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