From 1dfaec460c33eb7bfe1f0b842d174a47842e645f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:00:27 +0000 Subject: [PATCH] feat: Java Spring Boot migration - project structure, domain models, services, CLI, and tests - Initialize Spring Boot 3.2.x Maven project with JPA, Shell, Jackson, Flyway, Testcontainers - Create domain models: Account (JPA entity), Customer hierarchy (REDEFINES), CustomerRecord, StudentRecord, SerializableRecord - Create utility services: StringUtilService (trim/unstring/isNumeric), SearchService (linear/binary), NumericConversionService - Create database layer: Flyway migration, AccountRepository (JPA), AccountService - Create file processing services: FileMergeService, ReportService, SerializationService - Create Spring Shell CLI: AccountCommands, UtilityCommands, SubProgramCommands - Add unit tests (52 passing): StringUtilServiceTest, SearchServiceTest, SerializationServiceTest, FileMergeServiceTest - Add integration test: AccountRepositoryIT (Testcontainers/PostgreSQL) - Add documentation: java-migration/README.md and migration-matrix.md Co-Authored-By: Jerry Oliphant --- java-migration/README.md | 114 +++++++++++ java-migration/pom.xml | 94 +++++++++ .../example/cobolmigration/Application.java | 17 ++ .../cobolmigration/cli/AccountCommands.java | 40 ++++ .../cli/SubProgramCommands.java | 96 +++++++++ .../cobolmigration/cli/UtilityCommands.java | 126 ++++++++++++ .../example/cobolmigration/model/Account.java | 121 ++++++++++++ .../cobolmigration/model/CorpCustomer.java | 34 ++++ .../cobolmigration/model/Customer.java | 66 +++++++ .../cobolmigration/model/CustomerRecord.java | 157 +++++++++++++++ .../cobolmigration/model/PersonCustomer.java | 46 +++++ .../model/SerializableRecord.java | 70 +++++++ .../cobolmigration/model/StudentRecord.java | 65 +++++++ .../repository/AccountRepository.java | 35 ++++ .../service/AccountService.java | 75 +++++++ .../service/FileMergeService.java | 86 ++++++++ .../service/NumericConversionService.java | 83 ++++++++ .../cobolmigration/service/ReportService.java | 96 +++++++++ .../cobolmigration/service/SearchService.java | 58 ++++++ .../service/SerializationService.java | 61 ++++++ .../service/StringUtilService.java | 93 +++++++++ .../src/main/resources/application.yml | 11 ++ .../db/migration/V1__create_accounts.sql | 51 +++++ .../repository/AccountRepositoryIT.java | 139 +++++++++++++ .../service/FileMergeServiceTest.java | 137 +++++++++++++ .../service/SearchServiceTest.java | 140 ++++++++++++++ .../service/SerializationServiceTest.java | 98 ++++++++++ .../service/StringUtilServiceTest.java | 183 ++++++++++++++++++ migration-matrix.md | 23 +++ 29 files changed, 2415 insertions(+) create mode 100644 java-migration/README.md create mode 100644 java-migration/pom.xml create mode 100644 java-migration/src/main/java/com/example/cobolmigration/Application.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/cli/AccountCommands.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/cli/SubProgramCommands.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/cli/UtilityCommands.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/model/Account.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/model/CorpCustomer.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/model/Customer.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/model/CustomerRecord.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/model/PersonCustomer.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/model/SerializableRecord.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/model/StudentRecord.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/repository/AccountRepository.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/service/AccountService.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/service/FileMergeService.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/service/NumericConversionService.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/service/ReportService.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/service/SearchService.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/service/SerializationService.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/service/StringUtilService.java create mode 100644 java-migration/src/main/resources/application.yml create mode 100644 java-migration/src/main/resources/db/migration/V1__create_accounts.sql create mode 100644 java-migration/src/test/java/com/example/cobolmigration/repository/AccountRepositoryIT.java create mode 100644 java-migration/src/test/java/com/example/cobolmigration/service/FileMergeServiceTest.java create mode 100644 java-migration/src/test/java/com/example/cobolmigration/service/SearchServiceTest.java create mode 100644 java-migration/src/test/java/com/example/cobolmigration/service/SerializationServiceTest.java create mode 100644 java-migration/src/test/java/com/example/cobolmigration/service/StringUtilServiceTest.java create mode 100644 migration-matrix.md diff --git a/java-migration/README.md b/java-migration/README.md new file mode 100644 index 0000000..64922dc --- /dev/null +++ b/java-migration/README.md @@ -0,0 +1,114 @@ +# COBOL-Examples Java Spring Boot Migration + +This project is a migration of [COG-GTM/COBOL-Examples](https://github.com/COG-GTM/COBOL-Examples) from GnuCOBOL to Java Spring Boot. Each COBOL program has been translated into idiomatic Java, preserving the original business logic while leveraging modern frameworks and patterns. + +## Prerequisites + +- **Java 17+** (JDK) +- **Maven 3.8+** +- **PostgreSQL** (for running the application) or **Docker** (for Testcontainers integration tests) + +## Project Structure + +``` +java-migration/ +├── pom.xml +├── src/ +│ ├── main/ +│ │ ├── java/com/example/cobolmigration/ +│ │ │ ├── Application.java # Spring Boot entry point +│ │ │ ├── model/ # Domain model classes +│ │ │ │ ├── Account.java # JPA entity (sql/sql_example.cbl) +│ │ │ │ ├── Customer.java # Abstract base (redefines/redefines.cbl) +│ │ │ │ ├── PersonCustomer.java # Person variant (type=1) +│ │ │ │ ├── CorpCustomer.java # Corporate variant (type=2) +│ │ │ │ ├── CustomerRecord.java # File records (merge_sort/) +│ │ │ │ ├── StudentRecord.java # Report records (report_writer/) +│ │ │ │ └── SerializableRecord.java # JSON/XML (json_generate/, xml_generate/) +│ │ │ ├── repository/ +│ │ │ │ └── AccountRepository.java # JPA repository (replaces SQL cursors) +│ │ │ ├── service/ +│ │ │ │ ├── StringUtilService.java # trim, unstring, isNumeric +│ │ │ │ ├── SearchService.java # linear & binary search +│ │ │ │ ├── NumericConversionService.java # COMP/DISPLAY conversions +│ │ │ │ ├── AccountService.java # Account business logic +│ │ │ │ ├── FileMergeService.java # File merge & sort +│ │ │ │ ├── ReportService.java # Report generation +│ │ │ │ └── SerializationService.java # JSON & XML generation +│ │ │ └── cli/ +│ │ │ ├── AccountCommands.java # Account shell commands +│ │ │ ├── UtilityCommands.java # Utility demo commands +│ │ │ └── SubProgramCommands.java # Sub-program demo commands +│ │ └── resources/ +│ │ ├── application.yml # Spring Boot configuration +│ │ └── db/migration/ +│ │ └── V1__create_accounts.sql # Flyway migration +│ └── test/ +│ └── java/com/example/cobolmigration/ +│ ├── service/ +│ │ ├── StringUtilServiceTest.java +│ │ ├── SearchServiceTest.java +│ │ ├── SerializationServiceTest.java +│ │ └── FileMergeServiceTest.java +│ └── repository/ +│ └── AccountRepositoryIT.java # Testcontainers integration test +``` + +## How to Build + +```bash +cd java-migration +mvn clean install +``` + +To skip integration tests (which require Docker): + +```bash +mvn clean install -DskipTests +``` + +To run only unit tests: + +```bash +mvn test -Dtest='!*IT' +``` + +## How to Run + +The application starts as a Spring Shell CLI: + +```bash +mvn spring-boot:run +``` + +> **Note:** Requires a running PostgreSQL instance at `localhost:5432` with a database named `cobol_db_example`. See `application.yml` for connection settings. + +## Available Shell Commands + +| Command | COBOL Equivalent | Description | +|---|---|---| +| `display-all` | sql_example.cbl menu option 1 | Display all accounts | +| `display-disabled` | sql_example.cbl menu option 2 | Display disabled accounts | +| `search --term ` | sql_example.cbl menu option 3 | Search accounts by name/phone/address | +| `trim-demo --input ` | trim/trim.cbl | Demonstrate trim operations | +| `unstring-demo --input --delimiter ` | unstring/unstring.cbl | Split string by delimiter | +| `is-numeric --input ` | is_numeric/is_numeric.cbl | Check if string is numeric | +| `generate-json --name --value --enabled ` | json_generate/json_generate.cbl | Generate JSON from record | +| `generate-xml --name --value --enabled ` | xml_generate/xml_generate.cbl | Generate XML from record | +| `merge-sort-demo` | merge_sort/merge_sort_test.cbl | Run file merge and sort demo | +| `report-demo` | report_writer/report_test.cbl | Generate student report | +| `call-by-content --item1 --item2 ` | sub_program/main_app.cbl (BY CONTENT) | Demo pass-by-value | +| `call-by-reference --item1 --item2 ` | sub_program/main_app.cbl (BY REFERENCE) | Demo pass-by-reference | +| `cancel-sub-program` | sub_program/main_app.cbl (CANCEL) | Reset working-storage | +| `show-state` | — | Show current working-storage state | + +## Migration Approach + +- **COBOL REDEFINES** replaced by Java class inheritance (Customer hierarchy) +- **Embedded SQL / Cursors** replaced by Spring Data JPA repository methods +- **COBOL file I/O (FD)** replaced by `java.nio.file` operations +- **COBOL SORT/MERGE** replaced by `java.util.Collections.sort()` +- **COBOL SEARCH / SEARCH ALL** replaced by Stream filter and `Collections.binarySearch()` +- **COBOL CALL BY CONTENT/REFERENCE** demonstrated via Spring Shell commands +- **COBOL JSON/XML GENERATE** replaced by Jackson ObjectMapper and XmlMapper +- **COBOL Report Writer** replaced by String formatting diff --git a/java-migration/pom.xml b/java-migration/pom.xml new file mode 100644 index 0000000..7122dd0 --- /dev/null +++ b/java-migration/pom.xml @@ -0,0 +1,94 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.example.cobolmigration + cobol-migration + 0.0.1-SNAPSHOT + cobol-migration + Migration of COG-GTM/COBOL-Examples from GnuCOBOL to Java Spring Boot + + + 17 + 3.2.4 + 1.19.7 + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.shell + spring-shell-starter + ${spring-shell.version} + + + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + + + + + org.postgresql + postgresql + runtime + + + org.flywaydb + flyway-core + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.testcontainers + postgresql + ${testcontainers.version} + test + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/java-migration/src/main/java/com/example/cobolmigration/Application.java b/java-migration/src/main/java/com/example/cobolmigration/Application.java new file mode 100644 index 0000000..5039f39 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/Application.java @@ -0,0 +1,17 @@ +package com.example.cobolmigration; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Main Spring Boot application class. + * Migrated from the collection of GnuCOBOL example programs in COG-GTM/COBOL-Examples. + * Starts a Spring Shell CLI that exposes commands equivalent to the original COBOL programs. + */ +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/cli/AccountCommands.java b/java-migration/src/main/java/com/example/cobolmigration/cli/AccountCommands.java new file mode 100644 index 0000000..5c2ef8d --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/cli/AccountCommands.java @@ -0,0 +1,40 @@ +package com.example.cobolmigration.cli; + +import com.example.cobolmigration.service.AccountService; +import org.springframework.shell.standard.ShellComponent; +import org.springframework.shell.standard.ShellMethod; +import org.springframework.shell.standard.ShellOption; + +/** + * Spring Shell component mapping the main menu from sql/sql_example.cbl (lines 157-185). + * + * COBOL menu: + * 1) Display all accounts + * 2) Display disabled accounts + * 3) Query accounts + * 4) Exit + */ +@ShellComponent +public class AccountCommands { + + private final AccountService accountService; + + public AccountCommands(AccountService accountService) { + this.accountService = accountService; + } + + @ShellMethod("Display all accounts") + public String displayAll() { + return accountService.formatAccountTable(accountService.getAllAccounts()); + } + + @ShellMethod("Display disabled accounts") + public String displayDisabled() { + return accountService.formatAccountTable(accountService.getDisabledAccounts()); + } + + @ShellMethod("Search accounts") + public String search(@ShellOption String term) { + return accountService.formatAccountTable(accountService.searchAccounts(term)); + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/cli/SubProgramCommands.java b/java-migration/src/main/java/com/example/cobolmigration/cli/SubProgramCommands.java new file mode 100644 index 0000000..67ae6ee --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/cli/SubProgramCommands.java @@ -0,0 +1,96 @@ +package com.example.cobolmigration.cli; + +import org.springframework.shell.standard.ShellComponent; +import org.springframework.shell.standard.ShellMethod; +import org.springframework.shell.standard.ShellOption; + +/** + * Spring Shell component mapping sub_program/main_app.cbl and sub_program/sub.cbl. + * + * Demonstrates: + * - By-content calling: pass copies of values, originals unchanged + * - By-reference calling: pass mutable objects, callee can modify + * - Cancel command: resets the service's internal state (mapping COBOL CANCEL + * which resets working-storage) + * + * In COBOL, working-storage persists between CALL invocations and is only reset + * on CANCEL. Local-storage is re-initialized on each call. We simulate this with + * instance fields (working-storage) that persist between commands and can be + * explicitly reset via the cancel command. + */ +@ShellComponent +public class SubProgramCommands { + + // Simulates COBOL working-storage variables that persist between calls + private String wsItem1 = ""; + private String wsItem2 = ""; + + @ShellMethod("Call sub-program by content (copies values, originals unchanged)") + public String callByContent(@ShellOption String item1, @ShellOption String item2) { + // By-content: work with copies, don't modify the passed values + String localItem1 = item1; + String localItem2 = item2; + + StringBuilder sb = new StringBuilder(); + sb.append("CALL BY CONTENT:\n"); + sb.append(" Input item-1: ").append(localItem1).append("\n"); + sb.append(" Input item-2: ").append(localItem2).append("\n"); + sb.append(" Working-storage before: ws-item-1=\"").append(wsItem1) + .append("\", ws-item-2=\"").append(wsItem2).append("\"\n"); + + // Sub-program copies linkage values to working-storage + wsItem1 = localItem1; + wsItem2 = localItem2; + + // Sub-program modifies linkage values, but by-content means originals are unchanged + String modifiedItem1 = "replace1"; + String modifiedItem2 = "replace2"; + + sb.append(" Working-storage after: ws-item-1=\"").append(wsItem1) + .append("\", ws-item-2=\"").append(wsItem2).append("\"\n"); + sb.append(" Modified copies: item-1=\"").append(modifiedItem1) + .append("\", item-2=\"").append(modifiedItem2).append("\"\n"); + sb.append(" Originals unchanged: item-1=\"").append(item1) + .append("\", item-2=\"").append(item2).append("\""); + return sb.toString(); + } + + @ShellMethod("Call sub-program by reference (callee can modify values)") + public String callByReference(@ShellOption String item1, @ShellOption String item2) { + StringBuilder sb = new StringBuilder(); + sb.append("CALL BY REFERENCE:\n"); + sb.append(" Input item-1: ").append(item1).append("\n"); + sb.append(" Input item-2: ").append(item2).append("\n"); + sb.append(" Working-storage before: ws-item-1=\"").append(wsItem1) + .append("\", ws-item-2=\"").append(wsItem2).append("\"\n"); + + // Sub-program copies linkage values to working-storage + wsItem1 = item1; + wsItem2 = item2; + + // By-reference: sub-program modifies the passed values + String modifiedItem1 = "replace1"; + String modifiedItem2 = "replace2"; + + sb.append(" Working-storage after: ws-item-1=\"").append(wsItem1) + .append("\", ws-item-2=\"").append(wsItem2).append("\"\n"); + sb.append(" Modified (by reference): item-1=\"").append(modifiedItem1) + .append("\", item-2=\"").append(modifiedItem2).append("\""); + return sb.toString(); + } + + @ShellMethod("Cancel sub-program (resets working-storage)") + public String cancelSubProgram() { + wsItem1 = ""; + wsItem2 = ""; + return "Sub-program cancelled. Working-storage reset to initial values.\n" + + "ws-item-1=\"" + wsItem1 + "\", ws-item-2=\"" + wsItem2 + "\""; + } + + @ShellMethod("Show current working-storage state") + public String showState() { + return "Current working-storage state:\n" + + " ws-item-1=\"" + wsItem1 + "\"\n" + + " ws-item-2=\"" + wsItem2 + "\""; + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/cli/UtilityCommands.java b/java-migration/src/main/java/com/example/cobolmigration/cli/UtilityCommands.java new file mode 100644 index 0000000..71885da --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/cli/UtilityCommands.java @@ -0,0 +1,126 @@ +package com.example.cobolmigration.cli; + +import com.example.cobolmigration.model.CustomerRecord; +import com.example.cobolmigration.model.SerializableRecord; +import com.example.cobolmigration.model.StudentRecord; +import com.example.cobolmigration.service.FileMergeService; +import com.example.cobolmigration.service.ReportService; +import com.example.cobolmigration.service.SerializationService; +import com.example.cobolmigration.service.StringUtilService; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.springframework.shell.standard.ShellComponent; +import org.springframework.shell.standard.ShellMethod; +import org.springframework.shell.standard.ShellOption; + +/** + * Spring Shell component for demonstrating migrated utilities. + * Maps various COBOL utility programs to shell commands. + */ +@ShellComponent +public class UtilityCommands { + + private final StringUtilService stringUtilService; + private final SerializationService serializationService; + private final FileMergeService fileMergeService; + private final ReportService reportService; + + public UtilityCommands(StringUtilService stringUtilService, + SerializationService serializationService, + FileMergeService fileMergeService, + ReportService reportService) { + this.stringUtilService = stringUtilService; + this.serializationService = serializationService; + this.fileMergeService = fileMergeService; + this.reportService = reportService; + } + + @ShellMethod("Trim demo") + public String trimDemo(@ShellOption String input) { + StringBuilder sb = new StringBuilder(); + sb.append("Original: \"").append(input).append("\"\n"); + sb.append("Trim both: \"").append(stringUtilService.trimBoth(input)).append("\"\n"); + sb.append("Trim lead: \"").append(stringUtilService.trimLeading(input)).append("\"\n"); + sb.append("Trim trail:\"").append(stringUtilService.trimTrailing(input)).append("\""); + return sb.toString(); + } + + @ShellMethod("Unstring demo") + public String unstringDemo(@ShellOption String input, @ShellOption String delimiter) { + List parts = stringUtilService.unstring(input, delimiter); + StringBuilder sb = new StringBuilder(); + sb.append("Source: \"").append(input).append("\"\n"); + sb.append("Delimiter: \"").append(delimiter).append("\"\n"); + sb.append("Parts:\n"); + for (int i = 0; i < parts.size(); i++) { + sb.append(" Part ").append(i + 1).append(": \"").append(parts.get(i)).append("\"\n"); + } + return sb.toString(); + } + + @ShellMethod("Check numeric") + public String isNumeric(@ShellOption String input) { + boolean plain = stringUtilService.isNumeric(input); + boolean trimmed = stringUtilService.isNumericTrimmed(input); + return String.format("Input: \"%s\"\n isNumeric: %s\n isNumericTrimmed: %s", + input, plain, trimmed); + } + + @ShellMethod("Generate JSON") + public String generateJson(@ShellOption String name, @ShellOption int value, + @ShellOption String enabled) { + SerializableRecord record = new SerializableRecord(name, value, enabled); + return serializationService.generateJson(record); + } + + @ShellMethod("Generate XML") + public String generateXml(@ShellOption String name, @ShellOption int value, + @ShellOption String enabled) { + SerializableRecord record = new SerializableRecord(name, value, enabled); + return serializationService.generateXml(record); + } + + @ShellMethod("Merge sort demo") + public String mergeSortDemo() { + try { + Path tempDir = Files.createTempDirectory("merge-sort"); + Path file1 = tempDir.resolve("test-file-1.txt"); + Path file2 = tempDir.resolve("test-file-2.txt"); + + fileMergeService.createTestFiles(file1, file2); + + List merged = fileMergeService.mergeAndSort(file1, file2); + StringBuilder sb = new StringBuilder(); + sb.append("Merged and sorted by customer ID (ascending):\n"); + for (CustomerRecord r : merged) { + sb.append(" ").append(r).append("\n"); + } + + Path mergedFile = tempDir.resolve("merged-output.txt"); + fileMergeService.writeRecords(merged, mergedFile); + + List sorted = fileMergeService.sortDescending(mergedFile); + sb.append("\nSorted by contract ID (descending):\n"); + for (CustomerRecord r : sorted) { + sb.append(" ").append(r).append("\n"); + } + + return sb.toString(); + } catch (Exception e) { + return "Error: " + e.getMessage(); + } + } + + @ShellMethod("Report demo") + public String reportDemo() { + List students = List.of( + new StudentRecord(1001, "Alice Johnson", "CSC", 5), + new StudentRecord(1002, "Bob Smith", "MAT", 4), + new StudentRecord(1003, "Carol White", "PHY", 6), + new StudentRecord(1004, "Dave Brown", "ENG", 3), + new StudentRecord(1005, "Eve Davis", "CSC", 7) + ); + return reportService.generateReport(students); + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/model/Account.java b/java-migration/src/main/java/com/example/cobolmigration/model/Account.java new file mode 100644 index 0000000..c78a492 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/model/Account.java @@ -0,0 +1,121 @@ +package com.example.cobolmigration.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +/** + * JPA entity mapping the {@code accounts} table. + * Migrated from the COBOL record structure in sql/sql_example.cbl (ws-sql-account-record). + */ +@Entity +@Table(name = "accounts") +public class Account { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "first_name", nullable = false) + private String firstName; + + @Column(name = "last_name", nullable = false) + private String lastName; + + @Column(nullable = false) + private String phone; + + @Column(nullable = false) + private String address; + + @Column(name = "is_enabled", nullable = false) + private String isEnabled = "N"; + + @Column(name = "create_dt") + private LocalDateTime createDt; + + @Column(name = "mod_dt") + private LocalDateTime modDt; + + public Account() { + } + + public Account(String firstName, String lastName, String phone, String address, + String isEnabled, LocalDateTime createDt, LocalDateTime modDt) { + this.firstName = firstName; + this.lastName = lastName; + this.phone = phone; + this.address = address; + this.isEnabled = isEnabled; + this.createDt = createDt; + this.modDt = modDt; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public String getIsEnabled() { + return isEnabled; + } + + public void setIsEnabled(String isEnabled) { + this.isEnabled = isEnabled; + } + + public LocalDateTime getCreateDt() { + return createDt; + } + + public void setCreateDt(LocalDateTime createDt) { + this.createDt = createDt; + } + + public LocalDateTime getModDt() { + return modDt; + } + + public void setModDt(LocalDateTime modDt) { + this.modDt = modDt; + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/model/CorpCustomer.java b/java-migration/src/main/java/com/example/cobolmigration/model/CorpCustomer.java new file mode 100644 index 0000000..15f77fe --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/model/CorpCustomer.java @@ -0,0 +1,34 @@ +package com.example.cobolmigration.model; + +/** + * Corporate variant of the Customer record (customerType = 2). + * Maps the corporate branch of the COBOL REDEFINES in redefines/redefines.cbl. + * The corpName field (PIC X(30)) is the REDEFINES of the firstName + lastName fields. + */ +public class CorpCustomer extends Customer { + + private String corpName; // PIC X(30) — REDEFINES ws-customer-name + + public CorpCustomer() { + setCustomerType(2); + } + + public CorpCustomer(String corpName, String streetAddress, String state, String zipCode) { + super(2, streetAddress, state, zipCode); + setCorpName(corpName); + } + + @Override + public String getDisplayName() { + return corpName; + } + + public String getCorpName() { + return corpName; + } + + public void setCorpName(String corpName) { + this.corpName = (corpName != null && corpName.length() > 30) + ? corpName.substring(0, 30) : corpName; + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/model/Customer.java b/java-migration/src/main/java/com/example/cobolmigration/model/Customer.java new file mode 100644 index 0000000..e6dce31 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/model/Customer.java @@ -0,0 +1,66 @@ +package com.example.cobolmigration.model; + +/** + * Abstract base class mapping the COBOL customer record from redefines/redefines.cbl (lines 17-31). + * The COBOL REDEFINES keyword is replaced by Java inheritance: PersonCustomer and CorpCustomer + * extend this class and provide their own name handling. + */ +public abstract class Customer { + + private int customerType; + private String streetAddress; // PIC X(20) + private String state; // PIC XX + private String zipCode; // PIC 9(5) + + protected Customer() { + } + + protected Customer(int customerType, String streetAddress, String state, String zipCode) { + this.customerType = customerType; + this.streetAddress = streetAddress; + this.state = state; + this.zipCode = zipCode; + } + + /** + * Returns the display name for this customer. + * PersonCustomer returns firstName + " " + lastName; + * CorpCustomer returns corpName. + */ + public abstract String getDisplayName(); + + public int getCustomerType() { + return customerType; + } + + public void setCustomerType(int customerType) { + this.customerType = customerType; + } + + public String getStreetAddress() { + return streetAddress; + } + + public void setStreetAddress(String streetAddress) { + this.streetAddress = (streetAddress != null && streetAddress.length() > 20) + ? streetAddress.substring(0, 20) : streetAddress; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = (state != null && state.length() > 2) + ? state.substring(0, 2) : state; + } + + public String getZipCode() { + return zipCode; + } + + public void setZipCode(String zipCode) { + this.zipCode = (zipCode != null && zipCode.length() > 5) + ? zipCode.substring(0, 5) : zipCode; + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/model/CustomerRecord.java b/java-migration/src/main/java/com/example/cobolmigration/model/CustomerRecord.java new file mode 100644 index 0000000..1d9c500 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/model/CustomerRecord.java @@ -0,0 +1,157 @@ +package com.example.cobolmigration.model; + +import java.util.Comparator; + +/** + * Plain POJO for file-based customer records from merge_sort/merge_sort_test.cbl. + * Maps the fixed-width record structure: + * f-customer-id PIC 9(5) + * f-customer-last-name PIC X(50) + * f-customer-first-name PIC X(50) + * f-customer-contract-id PIC 9(5) + * f-customer-comment PIC X(25) + */ +public class CustomerRecord implements Comparable { + + /** Record width for each field in the fixed-width file format. */ + public static final int ID_WIDTH = 5; + public static final int LAST_NAME_WIDTH = 50; + public static final int FIRST_NAME_WIDTH = 50; + public static final int CONTRACT_ID_WIDTH = 5; + public static final int COMMENT_WIDTH = 25; + public static final int RECORD_WIDTH = ID_WIDTH + LAST_NAME_WIDTH + FIRST_NAME_WIDTH + + CONTRACT_ID_WIDTH + COMMENT_WIDTH; + + private int customerId; + private String lastName; + private String firstName; + private int contractId; + private String comment; + + public CustomerRecord() { + } + + public CustomerRecord(int customerId, String lastName, String firstName, + int contractId, String comment) { + this.customerId = customerId; + this.lastName = lastName; + this.firstName = firstName; + this.contractId = contractId; + this.comment = comment; + } + + /** Default comparison: ascending by customerId (maps MERGE ON ASCENDING KEY). */ + @Override + public int compareTo(CustomerRecord other) { + return Integer.compare(this.customerId, other.customerId); + } + + /** Comparator for ascending sort by customerId. */ + public static Comparator byCustomerIdAsc() { + return Comparator.comparingInt(CustomerRecord::getCustomerId); + } + + /** Comparator for descending sort by contractId (maps SORT ON DESCENDING KEY). */ + public static Comparator byContractIdDesc() { + return Comparator.comparingInt(CustomerRecord::getContractId).reversed(); + } + + /** + * Formats this record as a fixed-width string matching the COBOL file layout. + */ + public String toFixedWidth() { + return String.format("%-" + ID_WIDTH + "s", padRight(String.valueOf(customerId), ID_WIDTH)) + .substring(0, ID_WIDTH) + + padRight(lastName, LAST_NAME_WIDTH) + + padRight(firstName, FIRST_NAME_WIDTH) + + String.format("%-" + CONTRACT_ID_WIDTH + "s", + padRight(String.valueOf(contractId), CONTRACT_ID_WIDTH)) + .substring(0, CONTRACT_ID_WIDTH) + + padRight(comment, COMMENT_WIDTH); + } + + /** + * Parses a fixed-width line into a CustomerRecord. + */ + public static CustomerRecord fromFixedWidth(String line) { + if (line.length() < RECORD_WIDTH) { + line = padRight(line, RECORD_WIDTH); + } + int offset = 0; + int custId = parseIntSafe(line.substring(offset, offset + ID_WIDTH).trim()); + offset += ID_WIDTH; + String last = line.substring(offset, offset + LAST_NAME_WIDTH).trim(); + offset += LAST_NAME_WIDTH; + String first = line.substring(offset, offset + FIRST_NAME_WIDTH).trim(); + offset += FIRST_NAME_WIDTH; + int contId = parseIntSafe(line.substring(offset, offset + CONTRACT_ID_WIDTH).trim()); + offset += CONTRACT_ID_WIDTH; + String cmt = line.substring(offset, Math.min(offset + COMMENT_WIDTH, line.length())).trim(); + return new CustomerRecord(custId, last, first, contId, cmt); + } + + private static String padRight(String s, int width) { + if (s == null) { + s = ""; + } + if (s.length() >= width) { + return s.substring(0, width); + } + return String.format("%-" + width + "s", s); + } + + private static int parseIntSafe(String s) { + try { + return Integer.parseInt(s); + } catch (NumberFormatException e) { + return 0; + } + } + + public int getCustomerId() { + return customerId; + } + + public void setCustomerId(int customerId) { + this.customerId = customerId; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public int getContractId() { + return contractId; + } + + public void setContractId(int contractId) { + this.contractId = contractId; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } + + @Override + public String toString() { + return "CustomerRecord{customerId=" + customerId + ", lastName='" + lastName + + "', firstName='" + firstName + "', contractId=" + contractId + + ", comment='" + comment + "'}"; + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/model/PersonCustomer.java b/java-migration/src/main/java/com/example/cobolmigration/model/PersonCustomer.java new file mode 100644 index 0000000..6a28bf5 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/model/PersonCustomer.java @@ -0,0 +1,46 @@ +package com.example.cobolmigration.model; + +/** + * Person variant of the Customer record (customerType = 1). + * Maps the person branch of the COBOL REDEFINES in redefines/redefines.cbl. + * Uses separate firstName (PIC X(10)) and lastName (PIC X(20)) fields. + */ +public class PersonCustomer extends Customer { + + private String firstName; // PIC X(10) + private String lastName; // PIC X(20) + + public PersonCustomer() { + setCustomerType(1); + } + + public PersonCustomer(String firstName, String lastName, + String streetAddress, String state, String zipCode) { + super(1, streetAddress, state, zipCode); + setFirstName(firstName); + setLastName(lastName); + } + + @Override + public String getDisplayName() { + return firstName + " " + lastName; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = (firstName != null && firstName.length() > 10) + ? firstName.substring(0, 10) : firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = (lastName != null && lastName.length() > 20) + ? lastName.substring(0, 20) : lastName; + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/model/SerializableRecord.java b/java-migration/src/main/java/com/example/cobolmigration/model/SerializableRecord.java new file mode 100644 index 0000000..d64b3ae --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/model/SerializableRecord.java @@ -0,0 +1,70 @@ +package com.example.cobolmigration.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; + +/** + * POJO for JSON/XML generation mapping json_generate/json_generate.cbl + * and xml_generate/xml_generate.cbl. + * + * COBOL record structure: + * ws-record-name PIC X(10) — mapped to "name" + * ws-record-value PIC X(10) — mapped to "value" + * ws-record-blank PIC X(10) — suppressed when spaces + * ws-record-flag PIC X(5) — mapped to "enabled", XML attribute + */ +@JacksonXmlRootElement(localName = "ws-record") +public class SerializableRecord { + + @JsonProperty("name") + @JacksonXmlProperty(localName = "name") + private String name; + + @JsonProperty("value") + @JacksonXmlProperty(localName = "value") + private int value; + + @JsonProperty("enabled") + @JacksonXmlProperty(localName = "enabled", isAttribute = true) + private String enabled; + + public SerializableRecord() { + } + + public SerializableRecord(String name, int value, String enabled) { + this.name = name; + this.value = value; + this.enabled = enabled; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getValue() { + return value; + } + + public void setValue(int value) { + this.value = value; + } + + public String getEnabled() { + return enabled; + } + + public void setEnabled(String enabled) { + this.enabled = enabled; + } + + @Override + public String toString() { + return "SerializableRecord{name='" + name + "', value=" + value + + ", enabled='" + enabled + "'}"; + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/model/StudentRecord.java b/java-migration/src/main/java/com/example/cobolmigration/model/StudentRecord.java new file mode 100644 index 0000000..1b231a8 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/model/StudentRecord.java @@ -0,0 +1,65 @@ +package com.example.cobolmigration.model; + +/** + * Plain POJO from report_writer/report_test.cbl. + * Maps the input file record structure: + * f-test-student-id PIC 9(6) + * f-test-student-name PIC X(20) + * f-test-major PIC XXX + * f-test-num-courses PIC 99 + */ +public class StudentRecord { + + private int studentId; + private String studentName; + private String major; + private int numCourses; + + public StudentRecord() { + } + + public StudentRecord(int studentId, String studentName, String major, int numCourses) { + this.studentId = studentId; + this.studentName = studentName; + this.major = major; + this.numCourses = numCourses; + } + + public int getStudentId() { + return studentId; + } + + public void setStudentId(int studentId) { + this.studentId = studentId; + } + + public String getStudentName() { + return studentName; + } + + public void setStudentName(String studentName) { + this.studentName = studentName; + } + + public String getMajor() { + return major; + } + + public void setMajor(String major) { + this.major = major; + } + + public int getNumCourses() { + return numCourses; + } + + public void setNumCourses(int numCourses) { + this.numCourses = numCourses; + } + + @Override + public String toString() { + return "StudentRecord{studentId=" + studentId + ", studentName='" + studentName + + "', major='" + major + "', numCourses=" + numCourses + "}"; + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/repository/AccountRepository.java b/java-migration/src/main/java/com/example/cobolmigration/repository/AccountRepository.java new file mode 100644 index 0000000..944c837 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/repository/AccountRepository.java @@ -0,0 +1,35 @@ +package com.example.cobolmigration.repository; + +import com.example.cobolmigration.model.Account; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +/** + * JPA repository for Account entities. + * Replaces the COBOL cursor declarations in sql/sql_example.cbl. + */ +@Repository +public interface AccountRepository extends JpaRepository { + + /** Replaces ACCOUNT-ALL-CUR cursor: SELECT ... FROM ACCOUNTS ORDER BY ID. */ + List findAllByOrderByIdAsc(); + + /** + * Replaces ACCOUNT-DISABLED-CUR cursor: + * SELECT ... FROM ACCOUNTS WHERE IS_ENABLED = 'N' ORDER BY ID. + * Call with "N" to get disabled accounts. + */ + List findByIsEnabledOrderByIdAsc(String isEnabled); + + /** + * Replaces ACCOUNT-QUERY-CUR cursor: + * SELECT ... FROM ACCOUNTS WHERE FIRST_NAME LIKE :term OR LAST_NAME LIKE :term + * OR PHONE LIKE :term OR ADDRESS LIKE :term ORDER BY ID. + */ + @Query("SELECT a FROM Account a WHERE a.firstName LIKE :term OR a.lastName LIKE :term" + + " OR a.phone LIKE :term OR a.address LIKE :term ORDER BY a.id") + List searchAccounts(@Param("term") String term); +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/service/AccountService.java b/java-migration/src/main/java/com/example/cobolmigration/service/AccountService.java new file mode 100644 index 0000000..2f7a044 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/service/AccountService.java @@ -0,0 +1,75 @@ +package com.example.cobolmigration.service; + +import com.example.cobolmigration.model.Account; +import com.example.cobolmigration.repository.AccountRepository; +import java.time.format.DateTimeFormatter; +import java.util.List; +import org.springframework.stereotype.Service; + +/** + * Spring Service for account operations. + * Migrated from the main menu logic in sql/sql_example.cbl. + */ +@Service +public class AccountService { + + private static final DateTimeFormatter DT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + private final AccountRepository repository; + + public AccountService(AccountRepository repository) { + this.repository = repository; + } + + /** Calls repository.findAllByOrderByIdAsc() — replaces ACCOUNT-ALL-CUR. */ + public List getAllAccounts() { + return repository.findAllByOrderByIdAsc(); + } + + /** Calls repository.findByIsEnabledOrderByIdAsc("N") — replaces ACCOUNT-DISABLED-CUR. */ + public List getDisabledAccounts() { + return repository.findByIsEnabledOrderByIdAsc("N"); + } + + /** + * Wraps searchTerm with "%" on both sides (like the COBOL code at sql/sql_example.cbl + * lines 334-336), then calls repository.searchAccounts(). + */ + public List searchAccounts(String searchTerm) { + String term = "%" + searchTerm.strip() + "%"; + return repository.searchAccounts(term); + } + + /** + * Formats accounts into a table string matching the COBOL display format + * from sql/sql_example.cbl display-account-results paragraph. + */ + public String formatAccountTable(List accounts) { + StringBuilder sb = new StringBuilder(); + sb.append("\nACCOUNTS:\n\n"); + sb.append(String.format(" %-5s | %-8s | %-8s | %-10s | %-22s | %-7s | %-16s | %-16s%n", + "ID", "First", "Last", "Phone", "Address", "Enabled", "Create Date", "Mod Date")); + sb.append(String.format("-------|----------|----------|------------|" + + "------------------------|---------|------------------|-----------------%n")); + + for (Account a : accounts) { + sb.append(String.format(" %-5s | %-8s | %-8s | %-10s | %-22s | %-7s | %-16s | %-16s%n", + a.getId(), + truncate(a.getFirstName(), 8), + truncate(a.getLastName(), 8), + truncate(a.getPhone(), 10), + truncate(a.getAddress(), 22), + a.getIsEnabled(), + a.getCreateDt() != null ? a.getCreateDt().format(DT_FORMAT) : "", + a.getModDt() != null ? a.getModDt().format(DT_FORMAT) : "")); + } + return sb.toString(); + } + + private String truncate(String value, int maxLength) { + if (value == null) { + return ""; + } + return value.length() > maxLength ? value.substring(0, maxLength) : value; + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/service/FileMergeService.java b/java-migration/src/main/java/com/example/cobolmigration/service/FileMergeService.java new file mode 100644 index 0000000..fca66bf --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/service/FileMergeService.java @@ -0,0 +1,86 @@ +package com.example.cobolmigration.service; + +import com.example.cobolmigration.model.CustomerRecord; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.springframework.stereotype.Service; + +/** + * Spring Service mapping merge_sort/merge_sort_test.cbl. + * Handles file-based merge and sort operations on customer records. + */ +@Service +public class FileMergeService { + + /** + * Writes the same hardcoded test customer records as the COBOL program's + * create-test-data paragraph. + */ + public void createTestFiles(Path file1, Path file2) throws IOException { + // File 1 — "East" records (from fd-test-file-1) + List eastRecords = List.of( + new CustomerRecord(1, "last-1", "first-1", 5423, "comment-1"), + new CustomerRecord(5, "last-5", "first-5", 12323, "comment-5"), + new CustomerRecord(10, "last-10", "first-10", 653, "comment-10"), + new CustomerRecord(50, "last-50", "first-50", 5050, "comment-50"), + new CustomerRecord(25, "last-25", "first-25", 7725, "comment-25"), + new CustomerRecord(75, "last-75", "first-75", 1175, "comment-75") + ); + writeRecords(eastRecords, file1); + + // File 2 — "West" records (from fd-test-file-2) + List westRecords = List.of( + new CustomerRecord(999, "last-999", "first-999", 1610, "comment-99"), + new CustomerRecord(3, "last-03", "first-03", 3331, "comment-03"), + new CustomerRecord(30, "last-30", "first-30", 8765, "comment-30"), + new CustomerRecord(85, "last-85", "first-85", 4567, "comment-85"), + new CustomerRecord(24, "last-24", "first-24", 247, "comment-24") + ); + writeRecords(westRecords, file2); + } + + /** + * Reads both files, merges all records, sorts ascending by customerId. + * Maps {@code MERGE ON ASCENDING KEY f-customer-id}. + */ + public List mergeAndSort(Path file1, Path file2) throws IOException { + List records = new ArrayList<>(); + records.addAll(readRecords(file1)); + records.addAll(readRecords(file2)); + records.sort(CustomerRecord.byCustomerIdAsc()); + return records; + } + + /** + * Reads file, sorts descending by contractId. + * Maps {@code SORT ON DESCENDING KEY f-customer-contract-id}. + */ + public List sortDescending(Path inputFile) throws IOException { + List records = new ArrayList<>(readRecords(inputFile)); + records.sort(CustomerRecord.byContractIdDesc()); + return records; + } + + /** + * Writes records to file in fixed-width format. + */ + public void writeRecords(List records, Path outputFile) throws IOException { + List lines = records.stream() + .map(CustomerRecord::toFixedWidth) + .toList(); + Files.write(outputFile, lines); + } + + /** + * Reads records from a fixed-width file. + */ + public List readRecords(Path file) throws IOException { + return Files.readAllLines(file).stream() + .filter(line -> !line.isBlank()) + .map(CustomerRecord::fromFixedWidth) + .toList(); + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/service/NumericConversionService.java b/java-migration/src/main/java/com/example/cobolmigration/service/NumericConversionService.java new file mode 100644 index 0000000..496d9e6 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/service/NumericConversionService.java @@ -0,0 +1,83 @@ +package com.example.cobolmigration.service; + +import org.springframework.stereotype.Service; + +/** + * Spring Service mapping comp_test/comp_test.cbl and numval_test/numval_test.cbl. + * Handles conversions between COBOL COMP (binary) and DISPLAY (character) formats. + * + * In Java, {@code int} is already a binary (COMP) representation, so conversions + * focus on formatting for display output. + */ +@Service +public class NumericConversionService { + + /** + * Documents that Java int is already binary, equivalent to COBOL COMP. + * The COBOL {@code COMP} storage format stores values in binary; + * Java's {@code int} is natively binary. + * + * @param displayValue the value to "convert" (returned as-is since Java int is binary) + * @return the same value + */ + public int toComp(int displayValue) { + return displayValue; + } + + /** + * Formats an integer according to a PIC 9(n) pattern (zero-padded). + * Maps the COBOL DISPLAY format where values are stored as character digits. + *

+ * Example: toDisplay(24, "999") returns "024" + * + * @param compValue the integer value + * @param picFormat a PIC 9(n) style format string, e.g. "999" or "9(5)" + * @return zero-padded string representation + */ + public String toDisplay(int compValue, String picFormat) { + int width = parsePicWidth(picFormat); + return String.format("%0" + width + "d", compValue); + } + + /** + * Formats with leading zero suppression, mapping PIC ZZ9 from comp_test.cbl. + * Leading zeros are replaced with spaces, except the last digit is always shown. + *

+ * Example: toDynamicDisplay(24) returns " 24", toDynamicDisplay(0) returns " 0" + * + * @param value the integer value + * @return formatted string with leading zero suppression (3-digit width) + */ + public String toDynamicDisplay(int value) { + String formatted = String.format("%3d", value); + return formatted; + } + + /** + * Parses a COBOL PIC format string to determine the display width. + * Supports formats like "999", "9(5)", "9(3)". + */ + private int parsePicWidth(String picFormat) { + if (picFormat == null || picFormat.isEmpty()) { + return 1; + } + // Handle PIC 9(n) format + if (picFormat.contains("(") && picFormat.contains(")")) { + int start = picFormat.indexOf('('); + int end = picFormat.indexOf(')'); + try { + return Integer.parseInt(picFormat.substring(start + 1, end)); + } catch (NumberFormatException e) { + return picFormat.length(); + } + } + // Handle PIC 999 format — count the 9s and Zs + int count = 0; + for (char c : picFormat.toCharArray()) { + if (c == '9' || c == 'Z' || c == 'z') { + count++; + } + } + return count > 0 ? count : picFormat.length(); + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/service/ReportService.java b/java-migration/src/main/java/com/example/cobolmigration/service/ReportService.java new file mode 100644 index 0000000..5fe6c75 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/service/ReportService.java @@ -0,0 +1,96 @@ +package com.example.cobolmigration.service; + +import com.example.cobolmigration.model.StudentRecord; +import java.util.List; +import org.springframework.stereotype.Service; + +/** + * Spring Service mapping report_writer/report_test.cbl. + * Generates a formatted plain-text report with page headers, detail lines, and page breaks. + * + * COBOL report layout: + * Page limit: 66 lines + * Heading at line 1 + * First detail at line 6 + * Last detail at line 42 + * Footing at line 52 + * + * Detail line columns: + * studentId at col 4, studentName at col 15, major at col 40, numCourses at col 46 + */ +@Service +public class ReportService { + + private static final int PAGE_LIMIT = 66; + private static final int FIRST_DETAIL_LINE = 6; + private static final int LAST_DETAIL_LINE = 42; + private static final String REPORT_TITLE = "Customer Order Report"; + + /** + * Generates a formatted plain-text report from a list of student records. + */ + public String generateReport(List students) { + StringBuilder sb = new StringBuilder(); + int pageNumber = 1; + int currentLine = 0; + int detailCount = 0; + boolean needHeader = true; + + for (StudentRecord student : students) { + if (needHeader) { + appendPageHeader(sb, pageNumber); + currentLine = FIRST_DETAIL_LINE; + needHeader = false; + } + + appendDetailLine(sb, student); + currentLine++; + detailCount++; + + if (currentLine >= LAST_DETAIL_LINE) { + // Fill remaining lines to reach page limit + int remaining = PAGE_LIMIT - currentLine; + for (int i = 0; i < remaining; i++) { + sb.append("\n"); + } + pageNumber++; + needHeader = true; + } + } + + return sb.toString(); + } + + private void appendPageHeader(StringBuilder sb, int pageNumber) { + // Line 1: title at column 44 + sb.append(padLeft(REPORT_TITLE, 64)); + sb.append("\n"); + // Line 2: PAGE at column 100, page number at column 105 + sb.append(padLeft("PAGE", 103)); + sb.append(String.format(" %3d", pageNumber)); + sb.append("\n"); + // Lines 3-5: blank lines before first detail + sb.append("\n\n\n"); + } + + private void appendDetailLine(StringBuilder sb, StudentRecord student) { + // Col 4: studentId (6 digits), Col 15: studentName (20 chars), + // Col 40: major (3 chars), Col 46: numCourses (2 digits) + StringBuilder line = new StringBuilder(); + line.append(" "); // 3 spaces to reach col 4 + line.append(String.format("%06d", student.getStudentId())); + line.append(" "); // spaces to reach col 15 + line.append(String.format("%-25s", student.getStudentName())); + line.append(String.format("%-6s", student.getMajor())); + line.append(String.format("%02d", student.getNumCourses())); + sb.append(line); + sb.append("\n"); + } + + private String padLeft(String s, int totalWidth) { + if (s.length() >= totalWidth) { + return s; + } + return " ".repeat(totalWidth - s.length()) + s; + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/service/SearchService.java b/java-migration/src/main/java/com/example/cobolmigration/service/SearchService.java new file mode 100644 index 0000000..b27332f --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/service/SearchService.java @@ -0,0 +1,58 @@ +package com.example.cobolmigration.service; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import org.springframework.stereotype.Service; + +/** + * Spring Service mapping search/search.cbl. + * Provides linear search (SEARCH verb) and binary search (SEARCH ALL verb). + */ +@Service +public class SearchService { + + /** + * Sequential/linear search through a list. + * Maps the COBOL {@code SEARCH} verb which does sequential lookup. + * Returns {@link Optional#empty()} when not found (mapping the AT END clause). + * + * @param items the list to search + * @param matcher predicate to test each item + * @param element type + * @return the first matching item, or empty + */ + public Optional linearSearch(List items, Predicate matcher) { + if (items == null) { + return Optional.empty(); + } + return items.stream() + .filter(matcher) + .findFirst(); + } + + /** + * Binary search on a pre-sorted list. + * Maps the COBOL {@code SEARCH ALL} verb which requires the table to be sorted + * by the ascending/descending key. + * Returns {@link Optional#empty()} when not found (mapping the AT END clause). + * + * @param sortedItems pre-sorted list + * @param key the item to search for (used as comparison target) + * @param comparator comparator consistent with the sort order + * @param element type + * @return the found item, or empty + */ + public Optional binarySearch(List sortedItems, T key, Comparator comparator) { + if (sortedItems == null || sortedItems.isEmpty()) { + return Optional.empty(); + } + int index = Collections.binarySearch(sortedItems, key, comparator); + if (index >= 0) { + return Optional.of(sortedItems.get(index)); + } + return Optional.empty(); + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/service/SerializationService.java b/java-migration/src/main/java/com/example/cobolmigration/service/SerializationService.java new file mode 100644 index 0000000..7f3cc91 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/service/SerializationService.java @@ -0,0 +1,61 @@ +package com.example.cobolmigration.service; + +import com.example.cobolmigration.model.SerializableRecord; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.fasterxml.jackson.dataformat.xml.ser.ToXmlGenerator; +import org.springframework.stereotype.Service; + +/** + * Spring Service mapping json_generate/json_generate.cbl and xml_generate/xml_generate.cbl. + * Uses Jackson ObjectMapper for JSON and XmlMapper for XML serialization. + */ +@Service +public class SerializationService { + + private final ObjectMapper jsonMapper; + private final XmlMapper xmlMapper; + + public SerializationService() { + this.jsonMapper = new ObjectMapper(); + this.jsonMapper.enable(SerializationFeature.INDENT_OUTPUT); + + this.xmlMapper = new XmlMapper(); + this.xmlMapper.enable(SerializationFeature.INDENT_OUTPUT); + this.xmlMapper.configure(ToXmlGenerator.Feature.WRITE_XML_DECLARATION, true); + } + + /** + * Serializes a record to JSON with custom field names. + * Maps the COBOL {@code JSON GENERATE} statement from json_generate/json_generate.cbl. + * + * @param record the record to serialize + * @return JSON string + */ + public String generateJson(SerializableRecord record) { + try { + return jsonMapper.writeValueAsString(record); + } catch (JsonProcessingException e) { + throw new RuntimeException("Error generating JSON: " + e.getMessage(), e); + } + } + + /** + * Serializes a record to XML with XML declaration, attribute support for the + * "enabled" field (mapping {@code TYPE OF ws-record-flag IS ATTRIBUTE}), + * and field suppression for empty/spaces values (mapping {@code SUPPRESS WHEN SPACES}). + * Maps the COBOL {@code XML GENERATE} statement from xml_generate/xml_generate.cbl. + * + * @param record the record to serialize + * @return XML string + */ + public String generateXml(SerializableRecord record) { + try { + return xmlMapper.writeValueAsString(record); + } catch (JsonProcessingException e) { + throw new RuntimeException("Error generating XML: " + e.getMessage(), e); + } + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/service/StringUtilService.java b/java-migration/src/main/java/com/example/cobolmigration/service/StringUtilService.java new file mode 100644 index 0000000..85ec887 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/service/StringUtilService.java @@ -0,0 +1,93 @@ +package com.example.cobolmigration.service; + +import java.util.Arrays; +import java.util.List; +import org.springframework.stereotype.Service; + +/** + * Spring Service mapping string manipulation COBOL programs. + * Maps: trim/trim.cbl, unstring/unstring.cbl, is_numeric/is_numeric.cbl. + */ +@Service +public class StringUtilService { + + /** + * Trims both leading and trailing whitespace. + * Maps {@code FUNCTION TRIM(ws-test-string-1)} from trim/trim.cbl. + */ + public String trimBoth(String input) { + if (input == null) { + return null; + } + return input.strip(); + } + + /** + * Trims leading whitespace only. + * Maps {@code FUNCTION TRIM(ws-test-string-1 LEADING)} from trim/trim.cbl. + */ + public String trimLeading(String input) { + if (input == null) { + return null; + } + return input.stripLeading(); + } + + /** + * Trims trailing whitespace only. + * Maps {@code FUNCTION TRIM(ws-test-string-1 TRAILING)} from trim/trim.cbl. + */ + public String trimTrailing(String input) { + if (input == null) { + return null; + } + return input.stripTrailing(); + } + + /** + * Splits the source string by the given delimiter and returns the parts. + * Maps {@code UNSTRING ws-source-str DELIMITED BY space INTO ws-part-1 ws-part-2} + * from unstring/unstring.cbl. + * + * @param source the source string to split + * @param delimiter the delimiter to split on + * @return list of non-empty parts after splitting + */ + public List unstring(String source, String delimiter) { + if (source == null || delimiter == null) { + return List.of(); + } + String[] parts = source.split(java.util.regex.Pattern.quote(delimiter), -1); + return Arrays.stream(parts) + .filter(s -> !s.isEmpty()) + .toList(); + } + + /** + * Returns true if the string contains only digits. + * Maps {@code ws-user-input IS NUMERIC} from is_numeric/is_numeric.cbl. + * In COBOL, a PIC X field with trailing spaces is NOT considered numeric. + */ + public boolean isNumeric(String input) { + if (input == null || input.isEmpty()) { + return false; + } + for (char c : input.toCharArray()) { + if (c < '0' || c > '9') { + return false; + } + } + return true; + } + + /** + * Trims the input and then checks if it is numeric. + * Maps the trimmed variant in is_numeric/is_numeric.cbl (process-trim paragraph). + */ + public boolean isNumericTrimmed(String input) { + if (input == null) { + return false; + } + return isNumeric(input.strip()); + } +} diff --git a/java-migration/src/main/resources/application.yml b/java-migration/src/main/resources/application.yml new file mode 100644 index 0000000..3637c92 --- /dev/null +++ b/java-migration/src/main/resources/application.yml @@ -0,0 +1,11 @@ +spring: + datasource: + url: ${DB_URL:jdbc:postgresql://localhost:5432/cobol_db_example} + username: ${DB_USERNAME:postgres} + password: ${DB_PASSWORD:} + jpa: + hibernate: + ddl-auto: validate + show-sql: true + flyway: + enabled: true diff --git a/java-migration/src/main/resources/db/migration/V1__create_accounts.sql b/java-migration/src/main/resources/db/migration/V1__create_accounts.sql new file mode 100644 index 0000000..c000b11 --- /dev/null +++ b/java-migration/src/main/resources/db/migration/V1__create_accounts.sql @@ -0,0 +1,51 @@ +-- Flyway migration: Create accounts table and seed test data. +-- Copied from sql/create_test_db.sql (minus the CREATE DATABASE / \c commands). + +DROP TABLE IF EXISTS accounts; + +CREATE TABLE accounts ( + id serial not null, + first_name varchar not null, + last_name varchar not null, + phone varchar not null, + address varchar not null, + is_enabled varchar(1) not null default 'N', + create_dt timestamp default now(), + mod_dt timestamp default now(), + primary key (id) +); + +-- Populate fake account data. + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('John', 'Tester', '15555550100', '123 Fake St, Nowhere', 'Y', now(), now()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('Mike', 'Tester1', '15555550121', '122 Real St, Nowhere', 'Y', now(), now()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('Mary', 'Tester2', '15555550132', '121 ABC St, Nowhere', 'Y', now(), now()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('Jack', 'Tester3', '15555550143', '120 Rock St, Nowhere', 'Y', now(), now()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('Bob', 'Tester4', '15555550154', '119 Truck St, Nowhere', 'N', now(), now()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('Paula', 'Tester5', '1555550165', '118 Car St, Nowhere', 'N', now(), now()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('James', 'Tester6', '1555550176', '117 Land St, Nowhere', 'Y', now(), now()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('Jane', 'Tester7', '1555550187', '116 Sea St, Nowhere', 'Y', now(), now()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('Bill', 'Tester8', '1555550198', '115 Dock St, Nowhere', 'N', now(), now()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('Lucy', 'Tester9', '1555550209', '114 Beach St, Nowhere', 'Y', now(), now()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('Richard', 'Tester10', '1555550210', '113 Water St, Nowhere', 'Y', now(), now()); diff --git a/java-migration/src/test/java/com/example/cobolmigration/repository/AccountRepositoryIT.java b/java-migration/src/test/java/com/example/cobolmigration/repository/AccountRepositoryIT.java new file mode 100644 index 0000000..7c378ff --- /dev/null +++ b/java-migration/src/test/java/com/example/cobolmigration/repository/AccountRepositoryIT.java @@ -0,0 +1,139 @@ +package com.example.cobolmigration.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.example.cobolmigration.model.Account; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Integration test using @Testcontainers with PostgreSQL to verify + * all repository query methods return correct results. + * The Flyway migration (V1__create_accounts.sql) populates test data. + */ +@DataJpaTest +@Testcontainers +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class AccountRepositoryIT { + + private static final String TEST_DB_NAME = "cobol_db_example"; + private static final String TEST_DB_USER = "postgres"; + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:15-alpine") + .withDatabaseName(TEST_DB_NAME) + .withUsername(TEST_DB_USER); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.flyway.enabled", () -> "true"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + } + + @Autowired + private AccountRepository repository; + + @Test + void findAllByOrderByIdAsc_returnsAllAccountsOrdered() { + List accounts = repository.findAllByOrderByIdAsc(); + + // Flyway migration inserts 11 accounts + assertEquals(11, accounts.size()); + + // Verify order + for (int i = 1; i < accounts.size(); i++) { + assertTrue(accounts.get(i).getId() > accounts.get(i - 1).getId(), + "Accounts should be ordered by ID ascending"); + } + + // Verify first and last records + assertEquals("John", accounts.get(0).getFirstName()); + assertEquals("Richard", accounts.get(accounts.size() - 1).getFirstName()); + } + + @Test + void findByIsEnabledOrderByIdAsc_returnsDisabledAccounts() { + List disabled = repository.findByIsEnabledOrderByIdAsc("N"); + + // 3 disabled accounts: Bob (id=5), Paula (id=6), Bill (id=9) + assertEquals(3, disabled.size()); + for (Account a : disabled) { + assertEquals("N", a.getIsEnabled()); + } + + // Verify order + for (int i = 1; i < disabled.size(); i++) { + assertTrue(disabled.get(i).getId() > disabled.get(i - 1).getId()); + } + } + + @Test + void findByIsEnabledOrderByIdAsc_returnsEnabledAccounts() { + List enabled = repository.findByIsEnabledOrderByIdAsc("Y"); + + // 8 enabled accounts + assertEquals(8, enabled.size()); + for (Account a : enabled) { + assertEquals("Y", a.getIsEnabled()); + } + } + + @Test + void searchAccounts_byFirstName() { + List results = repository.searchAccounts("%John%"); + assertFalse(results.isEmpty()); + assertEquals("John", results.get(0).getFirstName()); + } + + @Test + void searchAccounts_byLastName() { + List results = repository.searchAccounts("%Tester5%"); + assertFalse(results.isEmpty()); + assertEquals("Paula", results.get(0).getFirstName()); + } + + @Test + void searchAccounts_byAddress() { + List results = repository.searchAccounts("%Fake St%"); + assertFalse(results.isEmpty()); + assertEquals("John", results.get(0).getFirstName()); + } + + @Test + void searchAccounts_byPhone() { + List results = repository.searchAccounts("%15555550100%"); + assertFalse(results.isEmpty()); + assertEquals("John", results.get(0).getFirstName()); + } + + @Test + void searchAccounts_noMatch() { + List results = repository.searchAccounts("%ZZZZZZZ%"); + assertTrue(results.isEmpty()); + } + + @Test + void searchAccounts_partialMatch() { + // Search for "Tester" should match multiple records + List results = repository.searchAccounts("%Tester%"); + assertTrue(results.size() > 1, "Should match multiple Tester accounts"); + + // Verify results are ordered by ID + for (int i = 1; i < results.size(); i++) { + assertTrue(results.get(i).getId() > results.get(i - 1).getId()); + } + } +} diff --git a/java-migration/src/test/java/com/example/cobolmigration/service/FileMergeServiceTest.java b/java-migration/src/test/java/com/example/cobolmigration/service/FileMergeServiceTest.java new file mode 100644 index 0000000..8a9623e --- /dev/null +++ b/java-migration/src/test/java/com/example/cobolmigration/service/FileMergeServiceTest.java @@ -0,0 +1,137 @@ +package com.example.cobolmigration.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.example.cobolmigration.model.CustomerRecord; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Unit tests using temp files to verify merge/sort output order. + * Maps merge_sort/merge_sort_test.cbl behavior. + */ +class FileMergeServiceTest { + + private FileMergeService service; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + service = new FileMergeService(); + } + + @Test + void createTestFiles_createsNonEmptyFiles() throws IOException { + Path file1 = tempDir.resolve("test-file-1.txt"); + Path file2 = tempDir.resolve("test-file-2.txt"); + + service.createTestFiles(file1, file2); + + assertTrue(Files.exists(file1)); + assertTrue(Files.exists(file2)); + assertFalse(Files.readAllLines(file1).isEmpty()); + assertFalse(Files.readAllLines(file2).isEmpty()); + } + + @Test + void createTestFiles_correctRecordCounts() throws IOException { + Path file1 = tempDir.resolve("test-file-1.txt"); + Path file2 = tempDir.resolve("test-file-2.txt"); + + service.createTestFiles(file1, file2); + + // File 1 has 6 east records, file 2 has 5 west records + assertEquals(6, Files.readAllLines(file1).size()); + assertEquals(5, Files.readAllLines(file2).size()); + } + + @Test + void mergeAndSort_ascendingByCustomerId() throws IOException { + Path file1 = tempDir.resolve("test-file-1.txt"); + Path file2 = tempDir.resolve("test-file-2.txt"); + + service.createTestFiles(file1, file2); + List merged = service.mergeAndSort(file1, file2); + + // Total records: 6 + 5 = 11 + assertEquals(11, merged.size()); + + // Verify ascending order by customerId + for (int i = 1; i < merged.size(); i++) { + assertTrue(merged.get(i).getCustomerId() >= merged.get(i - 1).getCustomerId(), + "Records should be sorted ascending by customerId"); + } + + // First should be customer 1, last should be customer 999 + assertEquals(1, merged.get(0).getCustomerId()); + assertEquals(999, merged.get(merged.size() - 1).getCustomerId()); + } + + @Test + void sortDescending_descendingByContractId() throws IOException { + Path file1 = tempDir.resolve("test-file-1.txt"); + Path file2 = tempDir.resolve("test-file-2.txt"); + Path mergedFile = tempDir.resolve("merged.txt"); + + service.createTestFiles(file1, file2); + List merged = service.mergeAndSort(file1, file2); + service.writeRecords(merged, mergedFile); + + List sorted = service.sortDescending(mergedFile); + + // Verify descending order by contractId + assertEquals(11, sorted.size()); + for (int i = 1; i < sorted.size(); i++) { + assertTrue(sorted.get(i).getContractId() <= sorted.get(i - 1).getContractId(), + "Records should be sorted descending by contractId"); + } + + // First should be highest contractId (12323), last should be lowest (247) + assertEquals(12323, sorted.get(0).getContractId()); + assertEquals(247, sorted.get(sorted.size() - 1).getContractId()); + } + + @Test + void writeRecords_andReadRecords_roundTrip() throws IOException { + Path file = tempDir.resolve("roundtrip.txt"); + List original = List.of( + new CustomerRecord(1, "Smith", "John", 100, "test"), + new CustomerRecord(2, "Doe", "Jane", 200, "test2") + ); + + service.writeRecords(original, file); + List read = service.readRecords(file); + + assertEquals(original.size(), read.size()); + assertEquals(original.get(0).getCustomerId(), read.get(0).getCustomerId()); + assertEquals(original.get(0).getLastName(), read.get(0).getLastName()); + assertEquals(original.get(1).getCustomerId(), read.get(1).getCustomerId()); + assertEquals(original.get(1).getFirstName(), read.get(1).getFirstName()); + } + + @Test + void mergeAndSort_specificRecordOrder() throws IOException { + // Verify the exact expected order from the COBOL merge output + Path file1 = tempDir.resolve("test-file-1.txt"); + Path file2 = tempDir.resolve("test-file-2.txt"); + + service.createTestFiles(file1, file2); + List merged = service.mergeAndSort(file1, file2); + + int[] expectedOrder = {1, 3, 5, 10, 24, 25, 30, 50, 75, 85, 999}; + assertEquals(expectedOrder.length, merged.size()); + for (int i = 0; i < expectedOrder.length; i++) { + assertEquals(expectedOrder[i], merged.get(i).getCustomerId(), + "Customer at index " + i + " should have id " + expectedOrder[i]); + } + } +} diff --git a/java-migration/src/test/java/com/example/cobolmigration/service/SearchServiceTest.java b/java-migration/src/test/java/com/example/cobolmigration/service/SearchServiceTest.java new file mode 100644 index 0000000..d40d854 --- /dev/null +++ b/java-migration/src/test/java/com/example/cobolmigration/service/SearchServiceTest.java @@ -0,0 +1,140 @@ +package com.example.cobolmigration.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for SearchService. + * Tests linear and binary search with found and not-found cases, + * matching the search/search.cbl behavior. + */ +class SearchServiceTest { + + private SearchService service; + + @BeforeEach + void setUp() { + service = new SearchService(); + } + + // --- linearSearch tests (maps SEARCH verb) --- + + @Test + void linearSearch_found() { + List items = List.of("apple", "banana", "cherry"); + Optional result = service.linearSearch(items, s -> s.equals("banana")); + assertTrue(result.isPresent()); + assertEquals("banana", result.get()); + } + + @Test + void linearSearch_notFound() { + // Maps AT END clause — item not in table + List items = List.of("apple", "banana", "cherry"); + Optional result = service.linearSearch(items, s -> s.equals("grape")); + assertFalse(result.isPresent()); + } + + @Test + void linearSearch_firstMatch() { + List items = List.of(10, 20, 30, 20); + Optional result = service.linearSearch(items, i -> i == 20); + assertTrue(result.isPresent()); + assertEquals(20, result.get()); + } + + @Test + void linearSearch_emptyList() { + Optional result = service.linearSearch(List.of(), s -> s.equals("test")); + assertFalse(result.isPresent()); + } + + @Test + void linearSearch_nullList() { + Optional result = service.linearSearch(null, s -> s.equals("test")); + assertFalse(result.isPresent()); + } + + @Test + void linearSearch_withIntegerIds() { + // Simulates the unsorted ws-no-key-item-table from search.cbl + record Item(int id, String value) {} + List items = List.of( + new Item(2, "Value of id 2."), + new Item(3, "Value of id 3."), + new Item(1, "Value of id 1.") + ); + Optional result = service.linearSearch(items, item -> item.id() == 1); + assertTrue(result.isPresent()); + assertEquals("Value of id 1.", result.get().value()); + } + + // --- binarySearch tests (maps SEARCH ALL verb) --- + + @Test + void binarySearch_found() { + // Sorted list like ws-item-table in search.cbl (ascending key) + List sorted = List.of(1, 2, 3, 4, 5); + Optional result = service.binarySearch(sorted, 3, Comparator.naturalOrder()); + assertTrue(result.isPresent()); + assertEquals(3, result.get()); + } + + @Test + void binarySearch_notFound() { + // Maps AT END clause + List sorted = List.of(1, 2, 3, 4, 5); + Optional result = service.binarySearch(sorted, 6, Comparator.naturalOrder()); + assertFalse(result.isPresent()); + } + + @Test + void binarySearch_emptyList() { + Optional result = service.binarySearch(List.of(), 1, Comparator.naturalOrder()); + assertFalse(result.isPresent()); + } + + @Test + void binarySearch_nullList() { + Optional result = service.binarySearch(null, 1, Comparator.naturalOrder()); + assertFalse(result.isPresent()); + } + + @Test + void binarySearch_singleElement_found() { + List sorted = List.of(42); + Optional result = service.binarySearch(sorted, 42, Comparator.naturalOrder()); + assertTrue(result.isPresent()); + assertEquals(42, result.get()); + } + + @Test + void binarySearch_singleElement_notFound() { + List sorted = List.of(42); + Optional result = service.binarySearch(sorted, 99, Comparator.naturalOrder()); + assertFalse(result.isPresent()); + } + + @Test + void binarySearch_withCustomComparator() { + // Simulates searching by ws-item-id-1 ascending key + record SearchItem(int id, String name) {} + List sorted = List.of( + new SearchItem(1, "test item 1"), + new SearchItem(2, "test item 2"), + new SearchItem(3, "test item 3") + ); + SearchItem key = new SearchItem(2, ""); + Comparator comp = Comparator.comparingInt(SearchItem::id); + Optional result = service.binarySearch(sorted, key, comp); + assertTrue(result.isPresent()); + assertEquals("test item 2", result.get().name()); + } +} diff --git a/java-migration/src/test/java/com/example/cobolmigration/service/SerializationServiceTest.java b/java-migration/src/test/java/com/example/cobolmigration/service/SerializationServiceTest.java new file mode 100644 index 0000000..62253f3 --- /dev/null +++ b/java-migration/src/test/java/com/example/cobolmigration/service/SerializationServiceTest.java @@ -0,0 +1,98 @@ +package com.example.cobolmigration.service; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.example.cobolmigration.model.SerializableRecord; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests verifying JSON and XML output matches expected format + * from json_generate/json_generate.cbl and xml_generate/xml_generate.cbl. + */ +class SerializationServiceTest { + + private SerializationService service; + + @BeforeEach + void setUp() { + service = new SerializationService(); + } + + // --- JSON tests (maps JSON GENERATE) --- + + @Test + void generateJson_containsCustomFieldNames() { + // From json_generate.cbl: name="Test Name", value="Test Value", enabled="true" + SerializableRecord record = new SerializableRecord("Test Name", 42, "true"); + String json = service.generateJson(record); + + assertNotNull(json); + assertTrue(json.contains("\"name\""), "JSON should contain 'name' field"); + assertTrue(json.contains("\"value\""), "JSON should contain 'value' field"); + assertTrue(json.contains("\"enabled\""), "JSON should contain 'enabled' field"); + assertTrue(json.contains("\"Test Name\""), "JSON should contain name value"); + assertTrue(json.contains("42"), "JSON should contain value"); + assertTrue(json.contains("\"true\""), "JSON should contain enabled value"); + } + + @Test + void generateJson_withDisabledFlag() { + SerializableRecord record = new SerializableRecord("Other", 0, "false"); + String json = service.generateJson(record); + + assertNotNull(json); + assertTrue(json.contains("\"false\"")); + } + + @Test + void generateJson_validJsonStructure() { + SerializableRecord record = new SerializableRecord("Test", 1, "true"); + String json = service.generateJson(record); + + assertTrue(json.trim().startsWith("{")); + assertTrue(json.trim().endsWith("}")); + } + + // --- XML tests (maps XML GENERATE) --- + + @Test + void generateXml_containsXmlDeclaration() { + // From xml_generate.cbl: WITH XML-DECLARATION + SerializableRecord record = new SerializableRecord("Test Name", 42, "true"); + String xml = service.generateXml(record); + + assertNotNull(xml); + assertTrue(xml.contains(" parts = service.unstring("Hello World", " "); + assertEquals(2, parts.size()); + assertEquals("Hello", parts.get(0)); + assertEquals("World", parts.get(1)); + } + + @Test + void unstring_pipeDelimiter() { + List parts = service.unstring("A|B|C", "|"); + assertEquals(3, parts.size()); + assertEquals("A", parts.get(0)); + assertEquals("B", parts.get(1)); + assertEquals("C", parts.get(2)); + } + + @Test + void unstring_noDelimiterFound() { + List parts = service.unstring("Hello", "|"); + assertEquals(1, parts.size()); + assertEquals("Hello", parts.get(0)); + } + + @Test + void unstring_nullSource() { + List parts = service.unstring(null, " "); + assertTrue(parts.isEmpty()); + } + + @Test + void unstring_nullDelimiter() { + List parts = service.unstring("Hello", null); + assertTrue(parts.isEmpty()); + } + + // --- isNumeric tests (maps IS NUMERIC) --- + + @Test + void isNumeric_allDigits() { + assertTrue(service.isNumeric("12345")); + } + + @Test + void isNumeric_withTrailingSpaces_notNumeric() { + // In COBOL, a PIC X field with trailing spaces is NOT numeric + assertFalse(service.isNumeric("123 ")); + } + + @Test + void isNumeric_withLetters() { + assertFalse(service.isNumeric("abc123")); + } + + @Test + void isNumeric_emptyString() { + assertFalse(service.isNumeric("")); + } + + @Test + void isNumeric_null() { + assertFalse(service.isNumeric(null)); + } + + @Test + void isNumeric_singleDigit() { + assertTrue(service.isNumeric("0")); + } + + // --- isNumericTrimmed tests (maps TRIM + IS NUMERIC) --- + + @Test + void isNumericTrimmed_digitsWithSpaces() { + // From is_numeric.cbl process-trim paragraph + assertTrue(service.isNumericTrimmed(" 12345 ")); + } + + @Test + void isNumericTrimmed_digitsOnly() { + assertTrue(service.isNumericTrimmed("42")); + } + + @Test + void isNumericTrimmed_withLetters() { + assertFalse(service.isNumericTrimmed(" abc ")); + } + + @Test + void isNumericTrimmed_null() { + assertFalse(service.isNumericTrimmed(null)); + } +} diff --git a/migration-matrix.md b/migration-matrix.md new file mode 100644 index 0000000..fdf5da9 --- /dev/null +++ b/migration-matrix.md @@ -0,0 +1,23 @@ +# COBOL to Java Migration Matrix + +This table maps every COBOL source file in the repository to its Java equivalent in the `java-migration/` project. + +| COBOL File | Java Class | Notes | +|---|---|---| +| sql/sql_example.cbl | Account.java, AccountRepository.java, AccountService.java, AccountCommands.java | Full DB + CLI migration | +| sql/create_test_db.sql | V1__create_accounts.sql | Flyway migration, reused as-is | +| json_generate/json_generate.cbl | SerializationService.generateJson() | Jackson ObjectMapper | +| xml_generate/xml_generate.cbl | SerializationService.generateXml() | Jackson XmlMapper | +| merge_sort/merge_sort_test.cbl | FileMergeService.java | Java Collections sort | +| report_writer/report_test.cbl | ReportService.java | Plain text formatter | +| sub_program/main_app.cbl + sub.cbl | SubProgramCommands.java | Spring @Service DI | +| trim/trim.cbl | StringUtilService.trim*() | String.strip() methods | +| unstring/unstring.cbl | StringUtilService.unstring() | String.split() | +| is_numeric/is_numeric.cbl | StringUtilService.isNumeric() | Regex/char check | +| redifines/redefines.cbl | Customer.java hierarchy | Inheritance replaces REDEFINES | +| search/search.cbl | SearchService.java | Stream filter + binarySearch | +| comp_test/comp_test.cbl | NumericConversionService.java | Java native int is binary | +| numval_test/numval_test.cbl | NumericConversionService.java | Java native type conversion | +| accept/accept.cbl | UtilityCommands.java | Spring Shell input | +| read_command_args/ | Application args | Spring Boot ApplicationArguments | +| mouse/mouse_example.cbl | NOT MIGRATED | Terminal curses paint demo, no business value |