diff --git a/spring-boot-app/README.md b/spring-boot-app/README.md
new file mode 100644
index 0000000..380cbb6
--- /dev/null
+++ b/spring-boot-app/README.md
@@ -0,0 +1,184 @@
+# COBOL-to-Spring Boot Migration
+
+This Spring Boot application is a Java migration of the COBOL example programs in this repository. It replaces the terminal-based, menu-driven COBOL programs with a REST API backed by Spring Data JPA and PostgreSQL.
+
+The original COBOL source files remain in the repository for reference.
+
+---
+
+## Migration Mapping
+
+### COBOL File → Java Class
+
+| COBOL Source File | Java Class | Description |
+|---|---|---|
+| `sql/sql_example.cbl` (lines 44-52) | `model/Account.java` | WORKING-STORAGE record → JPA @Entity |
+| `merge_sort/merge_sort_test.cbl` (lines 40-45) | `model/Customer.java` | File descriptor record → POJO |
+| `sql/sql_example.cbl` (lines 118-153) | `repository/AccountRepository.java` | SQL CURSORs → Spring Data query methods |
+| `sql/sql_example.cbl` (lines 201-394) | `service/AccountService.java` | PROCEDURE DIVISION paragraphs → service methods |
+| `json_generate/json_generate.cbl` | `service/SerializationService.java` | JSON GENERATE → Jackson ObjectMapper |
+| `xml_generate/xml_generate.cbl` | `service/SerializationService.java` | XML GENERATE → Jackson XmlMapper |
+| `sub_program/main_app.cbl`, `sub_program/sub.cbl` | `service/SubProgramService.java` | CALL BY CONTENT/REFERENCE → Java method calls |
+| `search/search.cbl` (lines 61-66) | `service/SearchService.java` | SEARCH ALL → Collections.binarySearch() |
+| `sql/sql_example.cbl` (menu, lines 157-185) | `controller/AccountController.java` | ACCEPT/DISPLAY menu → REST endpoints |
+| `json_generate/json_generate.cbl`, `xml_generate/xml_generate.cbl` | `controller/SerializationController.java` | Terminal output → REST endpoints |
+| `json_generate/json_generate.cbl` (lines 27-33) | `dto/SerializationRecord.java` | ws-record with NAME OF → @JsonProperty DTO |
+| `sql/create_test_db.sql` | `resources/schema.sql` | Database schema initialization |
+
+### COBOL Construct → Java Construct
+
+| COBOL Construct | Java Equivalent | Notes |
+|---|---|---|
+| `WORKING-STORAGE SECTION` | Instance fields / JPA @Entity | Persistent program state |
+| `LOCAL-STORAGE SECTION` | Local method variables | Re-initialized on each call |
+| `LINKAGE SECTION` | Method parameters | Data passed between programs |
+| `PIC X(n)` | `String` (max length n) | Character data |
+| `PIC 9(n)` | `int` / `Integer` | Numeric data |
+| `PIC X VALUE 'Y'/'N'` | `boolean` | Flag fields |
+| `EXEC SQL ... DECLARE CURSOR` | `JpaRepository` query methods | Database cursors → Spring Data |
+| `EXEC SQL ... FETCH ... INTO` | JPA automatic mapping | Row-to-object mapping |
+| `EXEC SQL CONNECT TO` | `application.properties` datasource | Connection configuration |
+| `ACCEPT ... DISPLAY` | REST `@GetMapping` / `@PostMapping` | User I/O → HTTP API |
+| `JSON GENERATE ... NAME OF` | `@JsonProperty` + `ObjectMapper` | Serialization with field renaming |
+| `XML GENERATE ... NAME OF` | `@JacksonXmlProperty` + `XmlMapper` | XML serialization |
+| `CALL BY CONTENT` | Pass primitives / immutable args | Callee cannot modify caller state |
+| `CALL BY REFERENCE` | Pass mutable objects | Callee can modify shared state |
+| `CANCEL` | Reset instance state | Clear working-storage equivalent |
+| `SEARCH ALL` | `Collections.binarySearch()` | Binary search on sorted table |
+| `SEARCH` (sequential) | `Stream.filter().findFirst()` | Linear search |
+| `SORT ... MERGE` | `Collections.sort()` / `Comparator` | In-memory sorting |
+| `PERFORM ... UNTIL` | `while` / `for` loops | Iteration |
+| `EVALUATE ... WHEN` | `switch` / `if-else` | Conditional branching |
+| `STRING ... INTO` | `String.format()` / `StringBuilder` | String concatenation |
+| `FUNCTION TRIM` | `String.trim()` | Whitespace removal |
+| `SQLSTATE` / `SQLCODE` | Exception handling | Error state → try/catch |
+
+### SQL Cursor → Repository Method
+
+| COBOL Cursor | Repository Method | Purpose |
+|---|---|---|
+| `ACCOUNT-ALL-CUR` | `findAllByOrderByIdAsc()` | All accounts, ordered by ID |
+| `ACCOUNT-DISABLED-CUR` | `findByEnabledFalseOrderByIdAsc()` | Disabled accounts only |
+| `ACCOUNT-QUERY-CUR` | `searchAccounts(searchValue)` | LIKE search across multiple fields |
+
+### Menu Option → REST Endpoint
+
+| Menu Option | HTTP Method | Endpoint | Description |
+|---|---|---|---|
+| 1) Display all accounts | `GET` | `/api/accounts` | List all accounts |
+| 2) Display disabled accounts | `GET` | `/api/accounts/disabled` | List disabled accounts |
+| 3) Query accounts | `GET` | `/api/accounts/search?q={query}` | Search accounts by keyword |
+| — | `POST` | `/api/serialize/json` | Serialize record to JSON |
+| — | `POST` | `/api/serialize/xml` | Serialize record to XML |
+
+---
+
+## Prerequisites
+
+- **Java 17** or later
+- **Maven 3.8+**
+- **PostgreSQL** (for production; tests use an embedded H2 database)
+
+## Build
+
+```bash
+cd spring-boot-app
+mvn clean install
+```
+
+## Run
+
+### With PostgreSQL (production)
+
+Ensure PostgreSQL is running with the database configured in `application.properties`:
+
+```
+Database: cobol_db_example
+User: postgres
+Password: password
+Port: 5432
+```
+
+You can initialize the database with the original COBOL test data:
+
+```bash
+psql -U postgres -f ../sql/create_test_db.sql
+```
+
+Then start the application:
+
+```bash
+mvn spring-boot:run
+```
+
+### Run Tests Only
+
+Tests use an embedded H2 database and do not require PostgreSQL:
+
+```bash
+mvn test
+```
+
+## API Usage Examples
+
+```bash
+# Get all accounts
+curl http://localhost:8080/api/accounts
+
+# Get disabled accounts
+curl http://localhost:8080/api/accounts/disabled
+
+# Search accounts
+curl "http://localhost:8080/api/accounts/search?q=John"
+
+# Serialize to JSON
+curl -X POST http://localhost:8080/api/serialize/json \
+ -H "Content-Type: application/json" \
+ -d '{"name":"Test Name","value":"Test Value","enabled":"true"}'
+
+# Serialize to XML
+curl -X POST http://localhost:8080/api/serialize/xml \
+ -H "Content-Type: application/json" \
+ -d '{"name":"Test Name","value":"Test Value","enabled":"true"}'
+```
+
+## Project Structure
+
+```
+spring-boot-app/
+├── pom.xml
+├── README.md
+└── src/
+ ├── main/
+ │ ├── java/com/cobolmigration/
+ │ │ ├── CobolMigrationApplication.java
+ │ │ ├── controller/
+ │ │ │ ├── AccountController.java
+ │ │ │ └── SerializationController.java
+ │ │ ├── dto/
+ │ │ │ └── SerializationRecord.java
+ │ │ ├── model/
+ │ │ │ ├── Account.java
+ │ │ │ └── Customer.java
+ │ │ ├── repository/
+ │ │ │ └── AccountRepository.java
+ │ │ └── service/
+ │ │ ├── AccountService.java
+ │ │ ├── SearchService.java
+ │ │ ├── SerializationService.java
+ │ │ └── SubProgramService.java
+ │ └── resources/
+ │ ├── application.properties
+ │ └── schema.sql
+ └── test/
+ ├── java/com/cobolmigration/
+ │ ├── controller/
+ │ │ └── AccountControllerTest.java
+ │ ├── repository/
+ │ │ └── AccountRepositoryTest.java
+ │ └── service/
+ │ ├── AccountServiceTest.java
+ │ └── SearchServiceTest.java
+ └── resources/
+ └── application-test.properties
+```
diff --git a/spring-boot-app/pom.xml b/spring-boot-app/pom.xml
new file mode 100644
index 0000000..c6d83b1
--- /dev/null
+++ b/spring-boot-app/pom.xml
@@ -0,0 +1,76 @@
+
+
This application replaces the terminal-based COBOL programs with a + * REST API backed by Spring Boot and JPA. The original COBOL source files + * remain in the repository for reference.
+ */ +@SpringBootApplication +public class CobolMigrationApplication { + + public static void main(String[] args) { + SpringApplication.run(CobolMigrationApplication.class, args); + } +} diff --git a/spring-boot-app/src/main/java/com/cobolmigration/controller/AccountController.java b/spring-boot-app/src/main/java/com/cobolmigration/controller/AccountController.java new file mode 100644 index 0000000..016edd5 --- /dev/null +++ b/spring-boot-app/src/main/java/com/cobolmigration/controller/AccountController.java @@ -0,0 +1,65 @@ +package com.cobolmigration.controller; + +import com.cobolmigration.model.Account; +import com.cobolmigration.service.AccountService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * REST controller replacing the terminal-based menu from + * {@code sql/sql_example.cbl} and the screen I/O from + * {@code accept/accept.cbl}. + * + *The original COBOL menu: + *
+ * 1) Display all accounts → GET /api/accounts
+ * 2) Display disabled accounts → GET /api/accounts/disabled
+ * 3) Query accounts → GET /api/accounts/search?q={query}
+ * 4) Exit → (not applicable in REST API)
+ *
+ */
+@RestController
+@RequestMapping("/api/accounts")
+public class AccountController {
+
+ private final AccountService accountService;
+
+ public AccountController(AccountService accountService) {
+ this.accountService = accountService;
+ }
+
+ /**
+ * Returns all accounts (replaces menu option 1: display-all-accounts).
+ */
+ @GetMapping
+ public ResponseEntityReplaces the COBOL JSON GENERATE functionality from + * {@code json_generate/json_generate.cbl} and XML GENERATE from + * {@code xml_generate/xml_generate.cbl}.
+ */ +@RestController +@RequestMapping("/api/serialize") +public class SerializationController { + + private final SerializationService serializationService; + + public SerializationController(SerializationService serializationService) { + this.serializationService = serializationService; + } + + /** + * Accepts a record and returns its JSON representation. + * + *Replaces the JSON GENERATE statement with NAME OF mappings.
+ * + * @param record the record to serialize + * @return JSON string + */ + @PostMapping(value = "/json", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntityReplaces the XML GENERATE statement with NAME OF mappings + * and TYPE OF attribute handling.
+ * + * @param record the record to serialize + * @return XML string + */ + @PostMapping(value = "/xml", produces = MediaType.APPLICATION_XML_VALUE) + public ResponseEntityMaps to the COBOL record in {@code json_generate/json_generate.cbl} + * (lines 27-33) and {@code xml_generate/xml_generate.cbl} (lines 26-32):
+ *+ * 01 ws-record. + * 05 ws-record-name pic x(10). + * 05 ws-record-value pic x(10). + * 05 ws-record-blank pic x(10). + * 05 ws-record-flag pic x(5) value "false". + *+ * + *
The COBOL {@code NAME OF} mappings are handled via Jackson annotations:
+ *Migrated from the COBOL WORKING-STORAGE record defined in + * {@code sql/sql_example.cbl} (lines 44-52):
+ *+ * 01 ws-sql-account-record. + * 05 ws-sql-account-id pic 9(5). + * 05 ws-sql-account-first-name pic x(8). + * 05 ws-sql-account-last-name pic x(8). + * 05 ws-sql-account-phone pic x(10). + * 05 ws-sql-account-address pic x(22). + * 05 ws-sql-account-is-enabled pic x. + * 05 ws-sql-account-create-dt pic x(20). + * 05 ws-sql-account-mod-dt pic x(20). + *+ */ +@Entity +@Table(name = "accounts") +public class Account { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Integer id; + + @Column(name = "FIRST_NAME", length = 8, nullable = false) + private String firstName; + + @Column(name = "LAST_NAME", length = 8, nullable = false) + private String lastName; + + @Column(name = "PHONE", length = 10, nullable = false) + private String phone; + + @Column(name = "ADDRESS", length = 22, nullable = false) + private String address; + + /** + * Maps to the IS_ENABLED column which stores 'Y' or 'N' in the COBOL version. + * Uses Hibernate's YesNoConverter to translate between boolean and VARCHAR(1). + */ + @Convert(converter = YesNoConverter.class) + @Column(name = "IS_ENABLED", nullable = false) + private boolean enabled; + + @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, boolean enabled) { + this.firstName = firstName; + this.lastName = lastName; + this.phone = phone; + this.address = address; + this.enabled = enabled; + } + + public Integer getId() { + return id; + } + + public void setId(Integer 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 boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + 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; + } + + @Override + public String toString() { + return "Account{" + + "id=" + id + + ", firstName='" + firstName + '\'' + + ", lastName='" + lastName + '\'' + + ", phone='" + phone + '\'' + + ", address='" + address + '\'' + + ", enabled=" + enabled + + ", createDt=" + createDt + + ", modDt=" + modDt + + '}'; + } +} diff --git a/spring-boot-app/src/main/java/com/cobolmigration/model/Customer.java b/spring-boot-app/src/main/java/com/cobolmigration/model/Customer.java new file mode 100644 index 0000000..4ea632f --- /dev/null +++ b/spring-boot-app/src/main/java/com/cobolmigration/model/Customer.java @@ -0,0 +1,90 @@ +package com.cobolmigration.model; + +/** + * POJO representing a customer record. + * + *
Migrated from the COBOL file-descriptor record in + * {@code merge_sort/merge_sort_test.cbl} (lines 40-45):
+ *+ * 01 f-customer-record-sort. + * 05 f-customer-id pic 9(5). + * 05 f-customer-last-name pic x(50). + * 05 f-customer-first-name pic x(50). + * 05 f-customer-contract-id pic 9(5). + * 05 f-customer-comment pic x(25). + *+ * + *
This is a plain POJO (not JPA-managed) since the original COBOL + * program uses file-based SORT/MERGE rather than database storage.
+ */ +public class Customer { + + private int customerId; + private String lastName; + private String firstName; + private int contractId; + private String comment; + + public Customer() { + } + + public Customer(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; + } + + 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 "Customer{" + + "customerId=" + customerId + + ", lastName='" + lastName + '\'' + + ", firstName='" + firstName + '\'' + + ", contractId=" + contractId + + ", comment='" + comment + '\'' + + '}'; + } +} diff --git a/spring-boot-app/src/main/java/com/cobolmigration/repository/AccountRepository.java b/spring-boot-app/src/main/java/com/cobolmigration/repository/AccountRepository.java new file mode 100644 index 0000000..f5d2cc6 --- /dev/null +++ b/spring-boot-app/src/main/java/com/cobolmigration/repository/AccountRepository.java @@ -0,0 +1,65 @@ +package com.cobolmigration.repository; + +import com.cobolmigration.model.Account; +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; + +import java.util.List; + +/** + * Spring Data JPA repository for the {@link Account} entity. + * + *Replaces the SQL cursors declared in {@code sql/sql_example.cbl} + * (lines 118-153).
+ */ +@Repository +public interface AccountRepository extends JpaRepositoryReplaces ACCOUNT-ALL-CUR: + *
+ * DECLARE ACCOUNT-ALL-CUR CURSOR FOR + * SELECT ID, FIRST_NAME, LAST_NAME, PHONE, + * ADDRESS, IS_ENABLED, CREATE_DT, MOD_DT + * FROM ACCOUNTS ORDER BY ID; + *+ */ + List
Replaces ACCOUNT-DISABLED-CUR: + *
+ * DECLARE ACCOUNT-DISABLED-CUR CURSOR FOR + * SELECT ... FROM ACCOUNTS WHERE IS_ENABLED = 'N' ORDER BY ID; + *+ */ + List
Replaces ACCOUNT-QUERY-CUR: + *
+ * DECLARE ACCOUNT-QUERY-CUR CURSOR FOR + * SELECT ... FROM ACCOUNTS + * WHERE FIRST_NAME LIKE :ws-search-value + * OR LAST_NAME LIKE :ws-search-value + * OR PHONE LIKE :ws-search-value + * OR ADDRESS LIKE :ws-search-value + * ORDER BY ID; + *+ */ + @Query("SELECT a FROM Account a WHERE a.firstName LIKE :searchValue " + + "OR a.lastName LIKE :searchValue " + + "OR a.phone LIKE :searchValue " + + "OR a.address LIKE :searchValue " + + "ORDER BY a.id") + List
Replaces the PROCEDURE DIVISION paragraphs in {@code sql/sql_example.cbl} + * (lines 103-185) which implement a menu-driven flow for account queries.
+ */ +@Service +public class AccountService { + + private final AccountRepository accountRepository; + + public AccountService(AccountRepository accountRepository) { + this.accountRepository = accountRepository; + } + + /** + * Retrieves all accounts ordered by ID. + * + *Replaces the {@code display-all-accounts} paragraph (lines 201-246) + * which opens ACCOUNT-ALL-CUR, fetches all rows, and displays them.
+ */ + public ListReplaces the {@code display-disabled-accounts} paragraph (lines 258-295) + * which opens ACCOUNT-DISABLED-CUR and fetches rows where IS_ENABLED = 'N'.
+ */ + public ListReplaces the {@code query-accounts} paragraph (lines 318-394) which + * trims the user input, wraps it with '%' wildcards, and opens + * ACCOUNT-QUERY-CUR.
+ * + * @param query the search term to match against account fields + * @return list of matching accounts + */ + public ListMigrated from {@code search/search.cbl} (lines 61-66) which uses + * SEARCH ALL on an indexed table with ascending/descending keys:
+ *+ * SEARCH ALL ws-item-table + * AT END DISPLAY "Item not found." + * WHEN ws-item-id-1(idx) = ws-accept-id-1 + * PERFORM display-found-item + * END-SEARCH + *+ * + *
In COBOL, SEARCH ALL requires the table to be sorted by its key and + * performs a binary search. In Java, we use {@link Collections#binarySearch} + * to achieve the same O(log n) lookup.
+ */ +@Service +public class SearchService { + + /** + * Performs a binary search for an item by its ID in a pre-sorted list. + * + *The list must be sorted in ascending order by the item's ID + * (matching the COBOL requirement for SEARCH ALL with ascending key).
+ * + * @param items a list of {@link SearchableItem} sorted by ID ascending + * @param searchId the ID to search for + * @return an Optional containing the found item, or empty if not found + */ + public OptionalEquivalent to the COBOL sequential SEARCH (without ALL), which + * does not require sorting:
+ *+ * SEARCH ws-no-key-item-table + * AT END DISPLAY "Item not found." + * WHEN ws-no-key-id(idx-2) = ws-accept-id-1 + * ... + * END-SEARCH + *+ * + * @param items a list of items (need not be sorted) + * @param searchId the ID to search for + * @return an Optional containing the found item, or empty if not found + */ + public Optional
+ * 01 ws-item-table occurs 3 times + * ascending key is ws-item-id-1 ... + * 05 ws-item-id-1 pic 9(4). + * 05 ws-item-name pic x(16). + * 05 ws-item-date ... + *+ */ + public static class SearchableItem { + private final int id; + private final String name; + private final String date; + + public SearchableItem(int id, String name, String date) { + this.id = id; + this.name = name; + this.date = date; + } + + public int getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDate() { + return date; + } + + @Override + public String toString() { + return "SearchableItem{id=" + id + + ", name='" + name + '\'' + + ", date='" + date + '\'' + '}'; + } + } +} diff --git a/spring-boot-app/src/main/java/com/cobolmigration/service/SerializationService.java b/spring-boot-app/src/main/java/com/cobolmigration/service/SerializationService.java new file mode 100644 index 0000000..60430b0 --- /dev/null +++ b/spring-boot-app/src/main/java/com/cobolmigration/service/SerializationService.java @@ -0,0 +1,69 @@ +package com.cobolmigration.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import org.springframework.stereotype.Service; + +/** + * Service for serializing objects to JSON and XML. + * + *
Replaces the COBOL JSON GENERATE statement in + * {@code json_generate/json_generate.cbl} (lines 42-54) and the + * XML GENERATE statement in {@code xml_generate/xml_generate.cbl} + * (lines 41-56).
+ * + *In COBOL, the {@code NAME OF} clause maps working-storage field + * names to custom output names. In Java, this is handled via + * {@code @JsonProperty} annotations on the DTO classes.
+ */ +@Service +public class SerializationService { + + private final ObjectMapper jsonMapper; + private final XmlMapper xmlMapper; + + public SerializationService() { + this.jsonMapper = new ObjectMapper(); + this.xmlMapper = new XmlMapper(); + } + + /** + * Serializes the given object to a JSON string. + * + *Replaces: + *
+ * JSON GENERATE ws-json-output + * FROM ws-record + * COUNT IN ws-json-char-count + * NAME OF ws-record-name IS "name", ... + *+ * + * @param object the object to serialize + * @return JSON string representation + * @throws JsonProcessingException if serialization fails + */ + public String toJson(Object object) throws JsonProcessingException { + return jsonMapper.writeValueAsString(object); + } + + /** + * Serializes the given object to an XML string. + * + *
Replaces: + *
+ * XML GENERATE ws-xml-output + * FROM ws-record + * COUNT IN ws-xml-char-count + * WITH XML-DECLARATION + * NAME OF ws-record-name IS "name", ... + *+ * + * @param object the object to serialize + * @return XML string representation + * @throws JsonProcessingException if serialization fails + */ + public String toXml(Object object) throws JsonProcessingException { + return xmlMapper.writeValueAsString(object); + } +} diff --git a/spring-boot-app/src/main/java/com/cobolmigration/service/SubProgramService.java b/spring-boot-app/src/main/java/com/cobolmigration/service/SubProgramService.java new file mode 100644 index 0000000..cd505f4 --- /dev/null +++ b/spring-boot-app/src/main/java/com/cobolmigration/service/SubProgramService.java @@ -0,0 +1,163 @@ +package com.cobolmigration.service; + +import org.springframework.stereotype.Service; + +/** + * Demonstrates the Java equivalent of COBOL subprogram CALL patterns. + * + *
Migrated from {@code sub_program/main_app.cbl} and + * {@code sub_program/sub.cbl} which demonstrate CALL BY CONTENT + * and CALL BY REFERENCE.
+ * + *In COBOL, subprogram WORKING-STORAGE variables persist between calls + * until a CANCEL is issued. In Java, this is modeled using instance fields + * that retain state between method calls.
+ */ +@Service +public class SubProgramService { + + private String workingStorageItem1 = ""; + private String workingStorageItem2 = ""; + + /** + * Simulates CALL BY CONTENT: accepts immutable values. + * The caller's variables are not modified because Java passes + * String values (immutable) rather than references to mutable storage. + * + *Equivalent to: + *
+ * CALL "sub-app" USING BY CONTENT ws-item-1 BY CONTENT ws-item-2 + *+ * + * @param item1 first value (not modified by this method) + * @param item2 second value (not modified by this method) + * @return result containing the processed values + */ + public SubProgramResult callByContent(String item1, String item2) { + this.workingStorageItem1 = item1; + this.workingStorageItem2 = item2; + + return new SubProgramResult(item1, item2, + workingStorageItem1, workingStorageItem2); + } + + /** + * Simulates CALL BY REFERENCE: accepts a mutable holder that can be + * modified by this method, just as the COBOL subprogram modifies + * linkage-section variables. + * + *
Equivalent to: + *
+ * CALL "sub-app" USING ws-item-1 ws-item-2 + *+ * + * @param holder mutable container whose values may be changed + * @return result containing the processed values + */ + public SubProgramResult callByReference(MutableHolder holder) { + this.workingStorageItem1 = holder.getItem1(); + this.workingStorageItem2 = holder.getItem2(); + + holder.setItem1("replace1"); + holder.setItem2("replace2"); + + return new SubProgramResult(holder.getItem1(), holder.getItem2(), + workingStorageItem1, workingStorageItem2); + } + + /** + * Resets internal working-storage state, equivalent to COBOL CANCEL. + * + *
Equivalent to: + *
+ * CANCEL "sub-app" + *+ */ + public void cancel() { + this.workingStorageItem1 = ""; + this.workingStorageItem2 = ""; + } + + public String getWorkingStorageItem1() { + return workingStorageItem1; + } + + public String getWorkingStorageItem2() { + return workingStorageItem2; + } + + /** + * Mutable holder to simulate CALL BY REFERENCE parameter passing. + */ + public static class MutableHolder { + private String item1; + private String item2; + + public MutableHolder() { + } + + public MutableHolder(String item1, String item2) { + this.item1 = item1; + this.item2 = item2; + } + + public String getItem1() { + return item1; + } + + public void setItem1(String item1) { + this.item1 = item1; + } + + public String getItem2() { + return item2; + } + + public void setItem2(String item2) { + this.item2 = item2; + } + } + + /** + * Immutable result returned from subprogram calls. + */ + public static class SubProgramResult { + private final String linkageItem1; + private final String linkageItem2; + private final String workingStorageItem1; + private final String workingStorageItem2; + + public SubProgramResult(String linkageItem1, String linkageItem2, + String workingStorageItem1, String workingStorageItem2) { + this.linkageItem1 = linkageItem1; + this.linkageItem2 = linkageItem2; + this.workingStorageItem1 = workingStorageItem1; + this.workingStorageItem2 = workingStorageItem2; + } + + public String getLinkageItem1() { + return linkageItem1; + } + + public String getLinkageItem2() { + return linkageItem2; + } + + public String getWorkingStorageItem1() { + return workingStorageItem1; + } + + public String getWorkingStorageItem2() { + return workingStorageItem2; + } + } +} diff --git a/spring-boot-app/src/main/resources/application.properties b/spring-boot-app/src/main/resources/application.properties new file mode 100644 index 0000000..3ce6b1c --- /dev/null +++ b/spring-boot-app/src/main/resources/application.properties @@ -0,0 +1,24 @@ +# ============================================================================= +# PostgreSQL Connection Configuration +# Mirrors the connection string from sql/sql_example.cbl: +# DRIVER={PostgreSQL Unicode};SERVER=localhost;PORT=5432; +# DATABASE=cobol_db_example;UID=postgres;PWD=password; +# ============================================================================= + +spring.datasource.url=jdbc:postgresql://localhost:5432/cobol_db_example +spring.datasource.username=postgres +spring.datasource.password=password +spring.datasource.driver-class-name=org.postgresql.Driver + +# JPA / Hibernate settings +spring.jpa.hibernate.ddl-auto=none +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +# Initialize schema from schema.sql on startup +spring.sql.init.mode=always +spring.sql.init.schema-locations=classpath:schema.sql + +# Server configuration +server.port=8080 diff --git a/spring-boot-app/src/main/resources/schema.sql b/spring-boot-app/src/main/resources/schema.sql new file mode 100644 index 0000000..e7db924 --- /dev/null +++ b/spring-boot-app/src/main/resources/schema.sql @@ -0,0 +1,19 @@ +-- ============================================================================= +-- Schema initialization script +-- Adapted from: sql/create_test_db.sql +-- +-- Creates the ACCOUNTS table used by the migrated COBOL SQL application. +-- This script is executed on application startup via spring.sql.init. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS 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) +); diff --git a/spring-boot-app/src/test/java/com/cobolmigration/controller/AccountControllerTest.java b/spring-boot-app/src/test/java/com/cobolmigration/controller/AccountControllerTest.java new file mode 100644 index 0000000..fec3fb3 --- /dev/null +++ b/spring-boot-app/src/test/java/com/cobolmigration/controller/AccountControllerTest.java @@ -0,0 +1,102 @@ +package com.cobolmigration.controller; + +import com.cobolmigration.model.Account; +import com.cobolmigration.service.AccountService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Web layer tests for {@link AccountController}. + */ +@WebMvcTest(AccountController.class) +class AccountControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AccountService accountService; + + @Test + void getAllAccounts_returnsOkWithAccounts() throws Exception { + Account account = createTestAccount(1, "John", "Tester", true); + when(accountService.getAllAccounts()).thenReturn(List.of(account)); + + mockMvc.perform(get("/api/accounts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].firstName", is("John"))) + .andExpect(jsonPath("$[0].lastName", is("Tester"))) + .andExpect(jsonPath("$[0].enabled", is(true))); + } + + @Test + void getAllAccounts_emptyList_returnsOk() throws Exception { + when(accountService.getAllAccounts()).thenReturn(List.of()); + + mockMvc.perform(get("/api/accounts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + + @Test + void getDisabledAccounts_returnsOnlyDisabled() throws Exception { + Account account = createTestAccount(2, "Bob", "Tester4", false); + when(accountService.getDisabledAccounts()).thenReturn(List.of(account)); + + mockMvc.perform(get("/api/accounts/disabled")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].firstName", is("Bob"))) + .andExpect(jsonPath("$[0].enabled", is(false))); + } + + @Test + void searchAccounts_returnsMatchingAccounts() throws Exception { + Account account = createTestAccount(1, "John", "Tester", true); + when(accountService.searchAccounts("John")).thenReturn(List.of(account)); + + mockMvc.perform(get("/api/accounts/search").param("q", "John")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].firstName", is("John"))); + } + + @Test + void searchAccounts_noResults_returnsEmptyList() throws Exception { + when(accountService.searchAccounts("NONEXISTENT")).thenReturn(List.of()); + + mockMvc.perform(get("/api/accounts/search").param("q", "NONEXISTENT")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + + @Test + void searchAccounts_missingQueryParam_returnsBadRequest() throws Exception { + mockMvc.perform(get("/api/accounts/search")) + .andExpect(status().isBadRequest()); + } + + private Account createTestAccount(int id, String firstName, String lastName, + boolean enabled) { + Account account = new Account(firstName, lastName, "1555550100", + "123 Fake St", enabled); + account.setId(id); + account.setCreateDt(LocalDateTime.now()); + account.setModDt(LocalDateTime.now()); + return account; + } +} diff --git a/spring-boot-app/src/test/java/com/cobolmigration/repository/AccountRepositoryTest.java b/spring-boot-app/src/test/java/com/cobolmigration/repository/AccountRepositoryTest.java new file mode 100644 index 0000000..1a0e11c --- /dev/null +++ b/spring-boot-app/src/test/java/com/cobolmigration/repository/AccountRepositoryTest.java @@ -0,0 +1,116 @@ +package com.cobolmigration.repository; + +import com.cobolmigration.model.Account; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link AccountRepository} using an embedded H2 database. + * + *
Test data mirrors the ACCOUNTS table structure from + * {@code sql/create_test_db.sql}.
+ */ +@DataJpaTest +@ActiveProfiles("test") +class AccountRepositoryTest { + + @Autowired + private AccountRepository accountRepository; + + @BeforeEach + void setUp() { + accountRepository.deleteAll(); + + LocalDateTime now = LocalDateTime.now(); + + Account account1 = new Account("John", "Tester", "1555550100", "123 Fake St, Nowhere", true); + account1.setCreateDt(now); + account1.setModDt(now); + + Account account2 = new Account("Bob", "Tester4", "1555550154", "119 Truck St, Nowhere", false); + account2.setCreateDt(now); + account2.setModDt(now); + + Account account3 = new Account("Paula", "Tester5", "1555550165", "118 Car St, Nowhere", false); + account3.setCreateDt(now); + account3.setModDt(now); + + Account account4 = new Account("Jane", "Tester7", "1555550187", "116 Sea St, Nowhere", true); + account4.setCreateDt(now); + account4.setModDt(now); + + accountRepository.saveAll(List.of(account1, account2, account3, account4)); + } + + @Test + void findAllByOrderByIdAsc_returnsAllAccountsOrdered() { + List