Add excel-export module: Apache POI 6.0.0 XSSF vs SXSSF under a fixed heap, 1,000,000 rows streamed with StreamingResponseBody, OpenCSV, sheet rolling, temp-file cleanup
Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b4VQVEJWetTT1dYzyJ
This commit is contained in:
@@ -9,6 +9,7 @@ files.
|
|||||||
|
|
||||||
| Directory | Article | What it demonstrates |
|
| Directory | Article | What it demonstrates |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| [`excel-export/`](excel-export) | [Export Excel and CSV from Spring Boot: Streaming 1 Million Rows with Apache POI SXSSF](https://ankurm.com/spring-boot-export-excel-csv-streaming-poi-sxssf/) | XSSF vs SXSSF under a fixed 512 MB heap (XSSF out of memory between 50,000 and 100,000 rows and at 1,000,000 rows even with 3 GB), 1,000,000 rows streamed with `StreamingResponseBody` under a 64 MB heap, sheet rolling at 1,048,576 rows, `compressTempFiles` cutting the spill from 475 MB to 56 MB, OpenCSV swallowing write errors, and an SXSSF file LibreOffice will not open |
|
||||||
| [`thymeleaf-htmx/`](thymeleaf-htmx) | [Spring Boot 4 + Thymeleaf + htmx: Server-Side CRUD UI in 2026](https://ankurm.com/spring-boot-thymeleaf-htmx-crud-ui/) | one URL answering as full page or fragment on `HX-Request`, a Thymeleaf layout, 422 validation re-render, pagination, inline edit and delete, 10 MockMvc tests plus a headless-Chromium check that observed htmx 2 ignoring a 204 and (without a config tag) a 422 |
|
| [`thymeleaf-htmx/`](thymeleaf-htmx) | [Spring Boot 4 + Thymeleaf + htmx: Server-Side CRUD UI in 2026](https://ankurm.com/spring-boot-thymeleaf-htmx-crud-ui/) | one URL answering as full page or fragment on `HX-Request`, a Thymeleaf layout, 422 validation re-render, pagination, inline edit and delete, 10 MockMvc tests plus a headless-Chromium check that observed htmx 2 ignoring a 204 and (without a config tag) a 422 |
|
||||||
| [`rate-limit/`](rate-limit) | [Rate Limiting Spring Boot APIs with Bucket4j and Redis: Per-User, Per-Key and Global Limits](https://ankurm.com/spring-boot-rate-limiting-bucket4j-redis/) | per-user, per-API-key and global token buckets in a servlet filter on Bucket4j 8.21 (`bucket4j_jdk17-*`), `429` with `Retry-After` and `X-RateLimit-*` headers, two application instances sharing one Redis bucket and 200 concurrent requests letting exactly 20 through, a limit raised in `application.yml` that does not change buckets already stored in Redis until a config version is bumped, a client clock 30 s ahead handing out tokens another instance already spent, and fail-open versus fail-closed when Redis dies |
|
| [`rate-limit/`](rate-limit) | [Rate Limiting Spring Boot APIs with Bucket4j and Redis: Per-User, Per-Key and Global Limits](https://ankurm.com/spring-boot-rate-limiting-bucket4j-redis/) | per-user, per-API-key and global token buckets in a servlet filter on Bucket4j 8.21 (`bucket4j_jdk17-*`), `429` with `Retry-After` and `X-RateLimit-*` headers, two application instances sharing one Redis bucket and 200 concurrent requests letting exactly 20 through, a limit raised in `application.yml` that does not change buckets already stored in Redis until a config version is bumped, a client clock 30 s ahead handing out tokens another instance already spent, and fail-open versus fail-closed when Redis dies |
|
||||||
| [`sbom/`](sbom) | [Spring Boot SBOM in Practice: the tools jar-mode sbom Command, CycloneDX and Dependency Scanning](https://ankurm.com/spring-boot-sbom-tools-jarmode-cyclonedx-dependency-scanning/) | the 4.2.0-RC1 `java -Djarmode=tools -jar app.jar sbom` command (absent in 4.1.2), the CycloneDX document the build puts in the jar, `/actuator/sbom`, an SBOM that lists 8 starters not in the jar and misses the jar mode's own library, a licence rule that wrongly flags dual-licensed logback, and an OSV scan that finds Log4Shell in a deliberately old build |
|
| [`sbom/`](sbom) | [Spring Boot SBOM in Practice: the tools jar-mode sbom Command, CycloneDX and Dependency Scanning](https://ankurm.com/spring-boot-sbom-tools-jarmode-cyclonedx-dependency-scanning/) | the 4.2.0-RC1 `java -Djarmode=tools -jar app.jar sbom` command (absent in 4.1.2), the CycloneDX document the build puts in the jar, `/actuator/sbom`, an SBOM that lists 8 starters not in the jar and misses the jar mode's own library, a licence rule that wrongly flags dual-licensed logback, and an OSV scan that finds Log4Shell in a deliberately old build |
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# excel-export
|
||||||
|
|
||||||
|
Companion code for **[Export Excel and CSV from Spring Boot: Streaming 1 Million Rows with Apache POI SXSSF](https://ankurm.com/spring-boot-export-excel-csv-streaming-poi-sxssf/)**.
|
||||||
|
|
||||||
|
Spring Boot 4.1.2, Spring Framework 7, JDK 25, Apache POI 6.0.0 (`poi-ooxml`), OpenCSV 5.12.0. No database, no Docker.
|
||||||
|
|
||||||
|
| Piece | Code | What it shows |
|
||||||
|
|---|---|---|
|
||||||
|
| XSSF endpoint | `ExportController#xssf` | whole workbook in heap; the baseline that runs out of memory |
|
||||||
|
| SXSSF endpoint | `ExportController#sxssf` | `SXSSFWorkbook(100)` + `StreamingResponseBody`, `dispose()` in `finally`, optional `compressTempFiles` |
|
||||||
|
| CSV endpoint | `ExportController#csv`, `CsvExporter` | OpenCSV streaming, UTF-8 BOM for Excel, formula-injection guard, swallowed write errors |
|
||||||
|
| Writer shared by XSSF and SXSSF | `XlsxExporter` | styles created once, freeze pane, auto filter, lakh/crore number format, sheet rolling at 1,048,576 rows |
|
||||||
|
| Data | `SaleSource` | rows computed from their index (stands in for a database cursor) |
|
||||||
|
| Diagnostics | `ProbeController` | peak used heap and peak RSS; delete before shipping |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/run-all.sh # tests + every measurement; rewrites output/*.txt (15-25 minutes)
|
||||||
|
curl -o sales.xlsx 'http://localhost:8080/export/sxssf?rows=1000000' # after: java -jar target/excel-export-1.0.0.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
`output/01..04` come from the tests, `output/05..09` from `scripts/measure.sh`, `output/10` from `scripts/libreoffice-check.sh`, which starts a fresh JVM with a fixed
|
||||||
|
`-Xmx` for every line. The numbers are indicative: they were taken on a shared 2-vCPU sandbox, one run per line.
|
||||||
|
|
||||||
|
Not run here: a real database cursor (rows are generated) and Microsoft Excel itself. LibreOffice 24.2.7.2 refuses to open
|
||||||
|
the SXSSF file because its ZIP entries carry a zero timestamp (same with POI 5.5.1); see `output/10-libreoffice.txt`.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
sheetLimit=1000 (header included), 2500 data rows
|
||||||
|
sheet 1 "Sales": last row index 999, first data cell 1
|
||||||
|
sheet 2 "Sales 2": last row index 999, first data cell 1000
|
||||||
|
sheet 3 "Sales 3": last row index 502, first data cell 1999
|
||||||
|
XSSF and SXSSF files: same sheets, same formatted cell values on every 97th row
|
||||||
|
|
||||||
|
styling of a 5,000-row SXSSF file, read back with XSSF
|
||||||
|
header cell A1 text=Order id bold=true
|
||||||
|
freeze pane: row split at 1
|
||||||
|
auto filter: A1:I1
|
||||||
|
cell styles in the workbook: 5 (for 5,000 rows x 9 cells)
|
||||||
|
B2 as Excel shows it: 30-May-2025
|
||||||
|
amount number format: [>=10000000]##\,##\,##\,##0.00;[>=100000]##\,##\,##0.00;##,##0.00
|
||||||
|
|
||||||
|
the sheet limit
|
||||||
|
createRow(1048575) ok
|
||||||
|
createRow(1048576) -> IllegalArgumentException: Invalid row number (1048576) outside allowable range (0..1048575)
|
||||||
|
|
||||||
|
lakh/crore number format, rendered by POI's DataFormatter (not by Excel)
|
||||||
|
999.50 -> 999.50
|
||||||
|
99,999.50 -> 99,999.50
|
||||||
|
123,456.78 -> 1,23,456.78
|
||||||
|
1,234,567.89 -> 12,34,567.89
|
||||||
|
12,345,678.90 -> 1,23,45,678.90
|
||||||
|
123,456,789.10 -> 12,34,56,789.10
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
after write(), no dispose(): 1 temp file(s) left: [poi-sxssf-sheet2787588911680201252.xml]
|
||||||
|
after close() only: 0 temp file(s) left
|
||||||
|
after dispose(): 0 temp file(s) left
|
||||||
|
|
||||||
|
temp file size after writing 50,000 rows (9 columns), before dispose():
|
||||||
|
compressTempFiles=false: 24,339,585 bytes
|
||||||
|
compressTempFiles=true: 2,974,461 bytes
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
first bytes: EF BB BF (UTF-8 BOM so Excel reads UTF-8)
|
||||||
|
rows written: 3
|
||||||
|
Order id,Order date,Region,Product,Qty,Unit price,Amount,Status,Note
|
||||||
|
1,2025-05-30,Chennai,Product-384,20,464.83,9296.60,RETURNED,INV/2025-26/0000001 dispatched via hub 71
|
||||||
|
2,2025-07-29,Pune,Product-121,20,596.20,11924.00,RETURNED,INV/2025-26/0000002 dispatched via hub 46
|
||||||
|
3,2025-11-22,Kolkata,Product-6,11,61.05,671.55,SHIPPED,INV/2025-26/0000003 dispatched via hub 51
|
||||||
|
|
||||||
|
comma, quote and newline inside fields: "Pune, MH","said ""hi""","line1\nline2"\n
|
||||||
|
|
||||||
|
=HYPERLINK("http://x","pay") -> '=HYPERLINK("http://x","pay")
|
||||||
|
+91 98200 00000 -> '+91 98200 00000
|
||||||
|
-2+3 -> '-2+3
|
||||||
|
@SUM(A1) -> '@SUM(A1)
|
||||||
|
Mumbai -> Mumbai
|
||||||
|
|
||||||
|
raw CSVWriter into a broken stream: writeNext called 10,000 times, none threw
|
||||||
|
checkError() = true, getException() = java.io.IOException: Broken pipe
|
||||||
|
CsvExporter into the same stream: IOException: CSV write failed (cause: java.io.IOException: Broken pipe)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
GET /export/xssf?rows=1500 -> 200, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, attachment; filename="sales.xlsx", content-length header: none (chunked: chunked), 91,345 bytes
|
||||||
|
GET /export/sxssf?rows=1500 -> 200, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, attachment; filename="sales.xlsx", content-length header: none (chunked: chunked), 93,481 bytes
|
||||||
|
GET /export/csv?rows=1500 -> 200, text/csv;charset=UTF-8, attachment; filename="sales.csv", content-length header: none (chunked: chunked), 152,833 bytes
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# XSSF under a fixed 512 MB heap: rows until it dies
|
||||||
|
# openjdk version "25.0.5" 2026-10-20 LTS | 2 vCPU | 8027 MB RAM
|
||||||
|
# indicative numbers from a shared sandbox, one run per line; see README
|
||||||
|
XSSF /export/xssf?rows=10000 Xmx=512m http=200 curl=0 bytes=576961 10.1s | peak used heap 135 MB | max after GC 112 MB | 18 GC pauses, longest 495 ms | peak RSS 397 MB | temp dir peak 0 MB, files left 0 | OOM=no
|
||||||
|
XSSF /export/xssf?rows=25000 Xmx=512m http=200 curl=0 bytes=1430086 13.8s | peak used heap 307 MB | max after GC 255 MB | 18 GC pauses, longest 327 ms | peak RSS 671 MB | temp dir peak 0 MB, files left 0 | OOM=no
|
||||||
|
XSSF /export/xssf?rows=50000 Xmx=512m http=200 curl=0 bytes=2851566 14.3s | peak used heap 494 MB | max after GC 474 MB | 26 GC pauses, longest 315 ms | peak RSS 674 MB | temp dir peak 0 MB, files left 0 | OOM=no
|
||||||
|
XSSF /export/xssf?rows=100000 Xmx=512m http=500 curl=0 bytes=107 68.3s | peak used heap - MB | max after GC 512 MB | 106 GC pauses, longest 2412 ms | peak RSS 667 MB | temp dir peak 0 MB, files left 0 | OOM=YES | body: {"timestamp":"2026-10-29T06:03:25.226Z","status":500,"error":"Internal Server Error","path":"/export/xssf"}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# SXSSF under the same 512 MB heap, same rows, plus 1,000,000
|
||||||
|
# openjdk version "25.0.5" 2026-10-20 LTS | 2 vCPU | 8027 MB RAM
|
||||||
|
# indicative numbers from a shared sandbox, one run per line; see README
|
||||||
|
SXSSF /export/sxssf?rows=10000 Xmx=512m http=200 curl=0 bytes=598239 10.6s | peak used heap 165 MB | max after GC 18 MB | 10 GC pauses, longest 57 ms | peak RSS 333 MB | temp dir peak 4 MB, files left 0 | OOM=no
|
||||||
|
SXSSF /export/sxssf?rows=25000 Xmx=512m http=200 curl=0 bytes=1490887 3.0s | peak used heap 211 MB | max after GC 18 MB | 11 GC pauses, longest 42 ms | peak RSS 366 MB | temp dir peak 11 MB, files left 0 | OOM=no
|
||||||
|
SXSSF /export/sxssf?rows=50000 Xmx=512m http=200 curl=0 bytes=2978433 6.5s | peak used heap 307 MB | max after GC 17 MB | 11 GC pauses, longest 82 ms | peak RSS 477 MB | temp dir peak 23 MB, files left 0 | OOM=no
|
||||||
|
SXSSF /export/sxssf?rows=100000 Xmx=512m http=200 curl=0 bytes=5954167 9.9s | peak used heap 306 MB | max after GC 22 MB | 12 GC pauses, longest 78 ms | peak RSS 484 MB | temp dir peak 46 MB, files left 0 | OOM=no
|
||||||
|
SXSSF /export/sxssf?rows=1000000 Xmx=512m http=200 curl=0 bytes=59408766 36.0s | peak used heap 321 MB | max after GC 19 MB | 29 GC pauses, longest 79 ms | peak RSS 504 MB | temp dir peak 475 MB, files left 0 | OOM=no
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 1,000,000 rows: SXSSF and CSV under a 64 MB heap, XSSF given a large heap
|
||||||
|
# openjdk version "25.0.5" 2026-10-20 LTS | 2 vCPU | 8027 MB RAM
|
||||||
|
# indicative numbers from a shared sandbox, one run per line; see README
|
||||||
|
SXSSF /export/sxssf?rows=1000000 Xmx=64m http=200 curl=0 bytes=59408766 37.5s | peak used heap 56 MB | max after GC 20 MB | 163 GC pauses, longest 43 ms | peak RSS 208 MB | temp dir peak 475 MB, files left 0 | OOM=no
|
||||||
|
CSV /export/csv?rows=1000000 Xmx=64m http=200 curl=0 bytes=104476584 4.3s | peak used heap 52 MB | max after GC 15 MB | 82 GC pauses, longest 40 ms | peak RSS 175 MB | temp dir peak 0 MB, files left 0 | OOM=no
|
||||||
|
XSSF /export/xssf?rows=1000000 Xmx=3g http=500 curl=0 bytes=107 273.6s | peak used heap - MB | max after GC 3072 MB | 132 GC pauses, longest 12794 ms | peak RSS 3295 MB | temp dir peak 0 MB, files left 0 | OOM=YES | body: {"timestamp":"2026-10-29T06:11:02.454Z","status":500,"error":"Internal Server Error","path":"/export/xssf"}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Sheet rolling: 1,100,000 rows, SXSSF, 64 MB heap, default sheet limit 1,048,576
|
||||||
|
# openjdk version "25.0.5" 2026-10-20 LTS | 2 vCPU | 8027 MB RAM
|
||||||
|
# indicative numbers from a shared sandbox, one run per line; see README
|
||||||
|
SXSSF /export/sxssf?rows=1100000 Xmx=64m http=200 curl=0 bytes=65376880 39.0s | peak used heap 56 MB | max after GC 25 MB | 178 GC pauses, longest 49 ms | peak RSS 209 MB | temp dir peak 523 MB, files left 0 | OOM=no
|
||||||
|
file size: 63 MB
|
||||||
|
<sheet name="Sales"
|
||||||
|
<sheet name="Sales 2"
|
||||||
|
sheet1.xml: 1048576 rows (header included)
|
||||||
|
sheet2.xml: 51426 rows (header included)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# compressTempFiles on a 1,000,000-row SXSSF export, 64 MB heap
|
||||||
|
# openjdk version "25.0.5" 2026-10-20 LTS | 2 vCPU | 8027 MB RAM
|
||||||
|
# indicative numbers from a shared sandbox, one run per line; see README
|
||||||
|
SXSSF /export/sxssf?rows=1000000&compress=false Xmx=64m http=200 curl=0 bytes=59408766 37.1s | peak used heap 55 MB | max after GC 19 MB | 169 GC pauses, longest 54 ms | peak RSS 209 MB | temp dir peak 475 MB, files left 0 | OOM=no
|
||||||
|
SXSSF /export/sxssf?rows=1000000&compress=true Xmx=64m http=200 curl=0 bytes=59408766 57.4s | peak used heap 56 MB | max after GC 22 MB | 168 GC pauses, longest 37 ms | peak RSS 208 MB | temp dir peak 56 MB, files left 0 | OOM=no
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
xssf.xlsx ZIP entry timestamps: [(2026, 10, 29, 11, 46, 52)]
|
||||||
|
sxssf.xlsx ZIP entry timestamps: [(1980, 0, 0, 0, 0, 0)]
|
||||||
|
LibreOffice 24.2.7.2 420(Build:2)
|
||||||
|
xssf.xlsx: LibreOffice loads it; row 2 -> 1,05/30/2025,Chennai,Product-384,20,464.83,9296.6,RETURNED,INV/2025-26/0000001 dispatched via hub 71
|
||||||
|
sxssf.xlsx: LibreOffice: source file could not be loaded
|
||||||
|
rezipped.xlsx: LibreOffice loads it; row 2 -> 1,05/30/2025,Chennai,Product-384,20,464.83,9296.6,RETURNED,INV/2025-26/0000001 dispatched via hub 71
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>4.1.2</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.ankurm</groupId>
|
||||||
|
<artifactId>excel-export</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>excel-export</name>
|
||||||
|
<description>Export Excel and CSV from Spring Boot 4.1: Apache POI XSSF vs SXSSF measured under a fixed heap,
|
||||||
|
StreamingResponseBody, OpenCSV, sheet rolling at Excel's row limit, and temp-file cleanup.</description>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>25</java.version>
|
||||||
|
<poi.version>6.0.0</poi.version>
|
||||||
|
<opencsv.version>5.12.0</opencsv.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.poi</groupId>
|
||||||
|
<artifactId>poi-ooxml</artifactId>
|
||||||
|
<version>${poi.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.opencsv</groupId>
|
||||||
|
<artifactId>opencsv</artifactId>
|
||||||
|
<version>${opencsv.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Opens the same 40-row report from the XSSF and the SXSSF endpoint in headless LibreOffice, and reports what it does.
|
||||||
|
# Needs: soffice, python3, curl, a built jar. Microsoft Excel itself was NOT available for this check.
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
d=$(mktemp -d); P="file://$d/profile"
|
||||||
|
java -jar target/excel-export-1.0.0.jar --server.port=38091 >/dev/null 2>&1 & p=$!
|
||||||
|
for _ in $(seq 1 120); do [ "$(curl -s localhost:38091/probe/ping)" = ok ] && break; sleep 1; done
|
||||||
|
curl -s -o "$d/xssf.xlsx" "localhost:38091/export/xssf?rows=40"
|
||||||
|
curl -s -o "$d/sxssf.xlsx" "localhost:38091/export/sxssf?rows=40"
|
||||||
|
kill $p; wait $p 2>/dev/null || true
|
||||||
|
python3 - "$d" <<'PY'
|
||||||
|
import sys, zipfile
|
||||||
|
d = sys.argv[1]
|
||||||
|
with zipfile.ZipFile(d + "/sxssf.xlsx") as z, zipfile.ZipFile(d + "/rezipped.xlsx", "w", zipfile.ZIP_DEFLATED) as out:
|
||||||
|
for n in z.namelist():
|
||||||
|
out.writestr(n, z.read(n))
|
||||||
|
for f in ("xssf", "sxssf"):
|
||||||
|
with zipfile.ZipFile(f"{d}/{f}.xlsx") as z:
|
||||||
|
print(f"{f}.xlsx ZIP entry timestamps: {sorted({i.date_time for i in z.infolist()})}")
|
||||||
|
PY
|
||||||
|
echo "$(soffice --version 2>/dev/null | head -1)"
|
||||||
|
for f in xssf sxssf rezipped; do
|
||||||
|
(cd "$d" && timeout 120 soffice --headless "-env:UserInstallation=$P" --convert-to csv "$f.xlsx" >/dev/null 2>&1)
|
||||||
|
if [ -f "$d/$f.csv" ]; then echo "$f.xlsx: LibreOffice loads it; row 2 -> $(sed -n 2p "$d/$f.csv")"; else echo "$f.xlsx: LibreOffice: source file could not be loaded"; fi
|
||||||
|
done
|
||||||
|
rm -rf "$d"
|
||||||
Executable
+51
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Library: run ONE export in a fresh JVM with a fixed heap and print one result line.
|
||||||
|
# measure LABEL URL_PATH XMX [SAVE_TO_FILE]
|
||||||
|
# Every call starts its own JVM, so the numbers of one approach never contaminate another.
|
||||||
|
# Needs: curl, awk, a built jar (mvn package), Linux (/proc).
|
||||||
|
set -u
|
||||||
|
JAR="${JAR:-target/excel-export-1.0.0.jar}"
|
||||||
|
PORT="${PORT:-$((30000 + RANDOM % 20000))}"
|
||||||
|
WORK="${WORK:-/tmp/excel-export-measure}"
|
||||||
|
|
||||||
|
measure() {
|
||||||
|
local label="$1" path="$2" xmx="$3" save="${4:-/dev/null}"
|
||||||
|
rm -rf "$WORK"; mkdir -p "$WORK/tmp"
|
||||||
|
java -Xms"$xmx" -Xmx"$xmx" -XX:+UseG1GC -Xlog:gc:file="$WORK/gc.log" -XX:OnOutOfMemoryError="touch $WORK/oom" \
|
||||||
|
-Djava.io.tmpdir="$WORK/tmp" -jar "$JAR" --server.port="$PORT" > "$WORK/app.log" 2>&1 &
|
||||||
|
local pid=$!
|
||||||
|
for _ in $(seq 1 400); do [ "$(curl -s "http://localhost:$PORT/probe/ping")" = ok ] && break; sleep 0.5; done
|
||||||
|
|
||||||
|
# sample the size of the temp directory while the download runs
|
||||||
|
( peak=0; while kill -0 "$pid" 2>/dev/null && [ ! -f "$WORK/done" ]; do
|
||||||
|
s=$(du -sk "$WORK/tmp" 2>/dev/null | cut -f1); [ "${s:-0}" -gt "$peak" ] && peak=$s; echo "$peak" > "$WORK/tmp.peak"; sleep 0.2
|
||||||
|
done ) &
|
||||||
|
local sampler=$!
|
||||||
|
|
||||||
|
local res
|
||||||
|
local body="$WORK/body.txt"; [ "$save" = /dev/null ] && save="$body"
|
||||||
|
res=$(curl -s --max-time "${MAX_TIME:-300}" -o "$save" -w "%{http_code} %{size_download} %{time_total}" "http://localhost:$PORT$path"; echo " curl_exit=$?")
|
||||||
|
touch "$WORK/done"; wait "$sampler" 2>/dev/null || true
|
||||||
|
|
||||||
|
local http bytes errbody="" secs cexit probe peakUsed rss
|
||||||
|
read -r http bytes secs _ <<< "$res"
|
||||||
|
cexit=$(echo "$res" | sed 's/.*curl_exit=//')
|
||||||
|
probe=$(curl -s --max-time 5 "http://localhost:$PORT/probe" || true)
|
||||||
|
peakUsed=$(echo "$probe" | sed -n 's/.*"peakUsedHeapMb":\([0-9]*\).*/\1/p')
|
||||||
|
rss=$(awk '/VmHWM/ {printf "%d", $2/1024}' "/proc/$pid/status" 2>/dev/null)
|
||||||
|
local tmpafter; tmpafter=$(find "$WORK/tmp" -type f 2>/dev/null | wc -l)
|
||||||
|
local oom=no; [ -f "$WORK/oom" ] && oom=YES
|
||||||
|
|
||||||
|
[ "$http" != 200 ] && [ -f "$body" ] && errbody=" | body: $(head -c 120 "$body" | tr -d '\n')"
|
||||||
|
kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true
|
||||||
|
|
||||||
|
# largest heap occupancy right after a collection, number of pauses, longest pause
|
||||||
|
local gc
|
||||||
|
gc=$(grep -oE '[0-9]+M->[0-9]+M\([0-9]+M\) [0-9.]+ms' "$WORK/gc.log" | tr '>()M-' ' ' | sed 's/ms//' \
|
||||||
|
| awk '{ if ($2>a) a=$2; n++; if ($4>p) p=$4 } END { printf "%d %d %.0f", a, n, p }')
|
||||||
|
local maxafter pauses maxpause; read -r maxafter pauses maxpause <<< "$gc"
|
||||||
|
local tmppeak; tmppeak=$(( $(cat "$WORK/tmp.peak" 2>/dev/null || echo 0) / 1024 ))
|
||||||
|
|
||||||
|
printf '%-7s %-34s Xmx=%-5s http=%-3s curl=%-2s bytes=%-10s %6.1fs | peak used heap %5s MB | max after GC %5s MB | %4s GC pauses, longest %4s ms | peak RSS %5s MB | temp dir peak %4s MB, files left %s | OOM=%s%s\n' \
|
||||||
|
"$label" "$path" "$xmx" "$http" "$cexit" "$bytes" "$secs" "${peakUsed:--}" "$maxafter" "$pauses" "$maxpause" "${rss:--}" "$tmppeak" "$tmpafter" "$oom" "$errbody"
|
||||||
|
}
|
||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regenerates every file in output/. Takes 15-25 minutes on a shared 2-vCPU box, mostly the XSSF runs.
|
||||||
|
# Needs JDK 25, Maven, curl, awk, unzip. LibreOffice is optional (step 10 is skipped without it).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
source scripts/measure.sh
|
||||||
|
mkdir -p output
|
||||||
|
HOST="$(java -version 2>&1 | grep -v JAVA_TOOL | head -1) | $(nproc) vCPU | $(awk '/MemTotal/ {printf "%d MB RAM", $2/1024}' /proc/meminfo)"
|
||||||
|
|
||||||
|
if [ -z "${SKIP_BUILD:-}" ]; then
|
||||||
|
mvn -q -B test # writes output/01..04 (structure, temp files, csv, endpoints)
|
||||||
|
mvn -q -B package -DskipTests
|
||||||
|
fi
|
||||||
|
|
||||||
|
hdr() { { echo "# $1"; echo "# $HOST"; echo "# indicative numbers from a shared sandbox, one run per line; see README"; } > "$2"; }
|
||||||
|
|
||||||
|
hdr "XSSF under a fixed 512 MB heap: rows until it dies" output/05-xssf-sweep.txt
|
||||||
|
for n in ${XSSF_ROWS:-10000 25000 50000 100000}; do
|
||||||
|
measure XSSF "/export/xssf?rows=$n" 512m >> output/05-xssf-sweep.txt
|
||||||
|
done
|
||||||
|
|
||||||
|
hdr "SXSSF under the same 512 MB heap, same rows, plus 1,000,000" output/06-sxssf-sweep.txt
|
||||||
|
for n in ${XSSF_ROWS:-10000 25000 50000 100000} 1000000; do
|
||||||
|
measure SXSSF "/export/sxssf?rows=$n" 512m >> output/06-sxssf-sweep.txt
|
||||||
|
done
|
||||||
|
|
||||||
|
hdr "1,000,000 rows: SXSSF and CSV under a 64 MB heap, XSSF given a large heap" output/07-million-rows.txt
|
||||||
|
measure SXSSF "/export/sxssf?rows=1000000" 64m >> output/07-million-rows.txt
|
||||||
|
measure CSV "/export/csv?rows=1000000" 64m >> output/07-million-rows.txt
|
||||||
|
measure XSSF "/export/xssf?rows=1000000" "${XSSF_BIG_HEAP:-3g}" >> output/07-million-rows.txt
|
||||||
|
|
||||||
|
hdr "Sheet rolling: 1,100,000 rows, SXSSF, 64 MB heap, default sheet limit 1,048,576" output/08-sheet-rolling.txt
|
||||||
|
measure SXSSF "/export/sxssf?rows=1100000" 64m /tmp/excel-export-big.xlsx >> output/08-sheet-rolling.txt
|
||||||
|
{
|
||||||
|
echo "file size: $(du -m /tmp/excel-export-big.xlsx | cut -f1) MB"
|
||||||
|
unzip -p /tmp/excel-export-big.xlsx xl/workbook.xml | grep -oE '<sheet [^>]*name="[^"]*"' | sed 's/ r:id.*//'
|
||||||
|
for s in 1 2; do
|
||||||
|
echo "sheet$s.xml: $(unzip -p /tmp/excel-export-big.xlsx xl/worksheets/sheet$s.xml | grep -o '<row ' | wc -l) rows (header included)"
|
||||||
|
done
|
||||||
|
} >> output/08-sheet-rolling.txt
|
||||||
|
rm -f /tmp/excel-export-big.xlsx
|
||||||
|
|
||||||
|
hdr "compressTempFiles on a 1,000,000-row SXSSF export, 64 MB heap" output/09-compress-temp.txt
|
||||||
|
measure SXSSF "/export/sxssf?rows=1000000&compress=false" 64m >> output/09-compress-temp.txt
|
||||||
|
measure SXSSF "/export/sxssf?rows=1000000&compress=true" 64m >> output/09-compress-temp.txt
|
||||||
|
|
||||||
|
if command -v soffice >/dev/null; then
|
||||||
|
bash scripts/libreoffice-check.sh > output/10-libreoffice.txt 2>&1 || true
|
||||||
|
fi
|
||||||
|
echo "done"
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.ankurm.excelexport;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.io.OutputStreamWriter;
|
||||||
|
import java.io.Writer;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Iterator;
|
||||||
|
|
||||||
|
import com.opencsv.CSVWriter;
|
||||||
|
import com.opencsv.ICSVWriter;
|
||||||
|
|
||||||
|
/** Streams {@link SaleRow}s as CSV with OpenCSV. Memory use does not depend on the row count. */
|
||||||
|
public final class CsvExporter {
|
||||||
|
|
||||||
|
private static final String[] HEADERS = {"Order id", "Order date", "Region", "Product", "Qty", "Unit price", "Amount", "Status", "Note"};
|
||||||
|
|
||||||
|
private CsvExporter() { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param excelBom write a UTF-8 byte-order mark so Excel opens the file as UTF-8 instead of the Windows code page
|
||||||
|
* @return the number of data rows written
|
||||||
|
*/
|
||||||
|
public static long write(Iterator<SaleRow> rows, OutputStream out, boolean excelBom) throws IOException {
|
||||||
|
if (excelBom) {
|
||||||
|
out.write(new byte[] {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF});
|
||||||
|
}
|
||||||
|
Writer w = new OutputStreamWriter(out, StandardCharsets.UTF_8);
|
||||||
|
ICSVWriter csv = new CSVWriter(w);
|
||||||
|
csv.writeNext(HEADERS, false);
|
||||||
|
long n = 0;
|
||||||
|
while (rows.hasNext()) {
|
||||||
|
SaleRow r = rows.next();
|
||||||
|
csv.writeNext(new String[] {
|
||||||
|
Long.toString(r.id()), r.orderDate().toString(), safe(r.region()), safe(r.product()),
|
||||||
|
Integer.toString(r.quantity()), r.unitPrice().toPlainString(), r.amount().toPlainString(),
|
||||||
|
safe(r.status()), safe(r.note())}, false);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
if (csv.checkError()) { // writeNext() swallows IOExceptions and only records them; see CsvTest
|
||||||
|
throw new IOException("CSV write failed", csv.getException());
|
||||||
|
}
|
||||||
|
csv.flush();
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV injection guard: a text cell that starts with = + - @ (or a tab or CR) is evaluated as a formula by
|
||||||
|
* Excel when the CSV is opened. Prefix a single quote so it is shown as text.
|
||||||
|
*/
|
||||||
|
static String safe(String s) {
|
||||||
|
if (s != null && !s.isEmpty()) {
|
||||||
|
char c = s.charAt(0);
|
||||||
|
if (c == '=' || c == '+' || c == '-' || c == '@' || c == '\t' || c == '\r') {
|
||||||
|
return "'" + s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.ankurm.excelexport;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class ExcelExportApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(ExcelExportApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.ankurm.excelexport;
|
||||||
|
|
||||||
|
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.springframework.http.ContentDisposition;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
public class ExportController {
|
||||||
|
|
||||||
|
static final MediaType XLSX = MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
static final MediaType CSV = MediaType.parseMediaType("text/csv;charset=UTF-8");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The naive version: XSSFWorkbook keeps every cell as an object until write(). StreamingResponseBody does
|
||||||
|
* not help here: the response is "streamed" only after the workbook has been built in full.
|
||||||
|
*/
|
||||||
|
@GetMapping("/export/xssf")
|
||||||
|
public ResponseEntity<StreamingResponseBody> xssf(@RequestParam(defaultValue = "100000") int rows,
|
||||||
|
@RequestParam(defaultValue = "" + XlsxExporter.EXCEL_MAX_ROWS) int sheetLimit) {
|
||||||
|
StreamingResponseBody body = out -> {
|
||||||
|
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||||
|
XlsxExporter.write(wb, SaleSource.rows(rows), sheetLimit, out);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return download("sales.xlsx", XLSX, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming version: SXSSFWorkbook(100) keeps 100 rows per sheet in memory and writes older rows to a temp
|
||||||
|
* file. dispose() deletes that temp file; it runs in finally so a failed or aborted download cleans up too.
|
||||||
|
*/
|
||||||
|
@GetMapping("/export/sxssf")
|
||||||
|
public ResponseEntity<StreamingResponseBody> sxssf(@RequestParam(defaultValue = "100000") int rows,
|
||||||
|
@RequestParam(defaultValue = "false") boolean compress,
|
||||||
|
@RequestParam(defaultValue = "100") int window,
|
||||||
|
@RequestParam(defaultValue = "" + XlsxExporter.EXCEL_MAX_ROWS) int sheetLimit) {
|
||||||
|
StreamingResponseBody body = out -> {
|
||||||
|
SXSSFWorkbook wb = new SXSSFWorkbook(window);
|
||||||
|
wb.setCompressTempFiles(compress);
|
||||||
|
try {
|
||||||
|
XlsxExporter.write(wb, SaleSource.rows(rows), sheetLimit, out);
|
||||||
|
} finally {
|
||||||
|
wb.dispose();
|
||||||
|
wb.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return download("sales.xlsx", XLSX, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/export/csv")
|
||||||
|
public ResponseEntity<StreamingResponseBody> csv(@RequestParam(defaultValue = "100000") int rows,
|
||||||
|
@RequestParam(defaultValue = "true") boolean bom) {
|
||||||
|
StreamingResponseBody body = out -> CsvExporter.write(SaleSource.rows(rows), out, bom);
|
||||||
|
return download("sales.csv", CSV, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResponseEntity<StreamingResponseBody> download(String filename, MediaType type, StreamingResponseBody body) {
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(filename).build().toString())
|
||||||
|
.contentType(type)
|
||||||
|
.body(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.ankurm.excelexport;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diagnostic endpoint for the measurements. It samples used heap every 20 ms and reports the peak, plus the
|
||||||
|
* process's peak resident set size from /proc (Linux only). Delete it before shipping anything.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class ProbeController {
|
||||||
|
|
||||||
|
private final AtomicLong peakUsed = new AtomicLong();
|
||||||
|
|
||||||
|
public ProbeController() {
|
||||||
|
Thread t = new Thread(() -> {
|
||||||
|
Runtime rt = Runtime.getRuntime();
|
||||||
|
while (true) {
|
||||||
|
peakUsed.accumulateAndGet(rt.totalMemory() - rt.freeMemory(), Math::max);
|
||||||
|
try {
|
||||||
|
Thread.sleep(20);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, "heap-peak-sampler");
|
||||||
|
t.setDaemon(true);
|
||||||
|
t.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/probe/ping")
|
||||||
|
public String ping() {
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/probe")
|
||||||
|
public Map<String, Object> probe() throws Exception {
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
Runtime rt = Runtime.getRuntime();
|
||||||
|
m.put("maxHeapMb", rt.maxMemory() >> 20);
|
||||||
|
m.put("peakUsedHeapMb", peakUsed.get() >> 20);
|
||||||
|
m.put("peakRssMb", procKb("VmHWM") >> 10);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long procKb(String key) throws Exception {
|
||||||
|
try (Stream<String> lines = Files.lines(Path.of("/proc/self/status"))) {
|
||||||
|
return lines.filter(l -> l.startsWith(key + ":"))
|
||||||
|
.map(l -> Long.parseLong(l.replaceAll("\\D+", "")))
|
||||||
|
.findFirst().orElse(-1L);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.ankurm.excelexport;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/** One line of the report. Nine columns once the computed amount is added; the note is unique per row on purpose. */
|
||||||
|
public record SaleRow(long id, LocalDate orderDate, String region, String product, int quantity,
|
||||||
|
BigDecimal unitPrice, String status, String note) {
|
||||||
|
|
||||||
|
public BigDecimal amount() {
|
||||||
|
return unitPrice.multiply(BigDecimal.valueOf(quantity));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.ankurm.excelexport;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.NoSuchElementException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A deterministic stand-in for a database cursor: row n is computed from n, so nothing is stored and the
|
||||||
|
* same call always yields the same rows. In a real service this is a JDBC {@code ResultSet} or a JPA
|
||||||
|
* {@code Stream<Entity>} with a fetch size; the exporters only ever see an {@link Iterator}.
|
||||||
|
*/
|
||||||
|
public final class SaleSource {
|
||||||
|
|
||||||
|
private static final String[] REGIONS = {"Mumbai", "Pune", "Delhi", "Bengaluru", "Chennai", "Hyderabad", "Kolkata", "Ahmedabad"};
|
||||||
|
private static final String[] STATUSES = {"PAID", "PENDING", "SHIPPED", "RETURNED"};
|
||||||
|
private static final LocalDate BASE = LocalDate.of(2025, 4, 1);
|
||||||
|
|
||||||
|
private SaleSource() { }
|
||||||
|
|
||||||
|
public static Iterator<SaleRow> rows(int count) {
|
||||||
|
return new Iterator<>() {
|
||||||
|
private long next = 1;
|
||||||
|
|
||||||
|
@Override public boolean hasNext() {
|
||||||
|
return next <= count;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public SaleRow next() {
|
||||||
|
if (!hasNext()) {
|
||||||
|
throw new NoSuchElementException();
|
||||||
|
}
|
||||||
|
return row(next++);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SaleRow row(long id) {
|
||||||
|
long h = id * 0x9E3779B97F4A7C15L; // cheap deterministic scrambler
|
||||||
|
int a = (int) ((h >>> 33) & 0x7fffffff);
|
||||||
|
int b = (int) ((h >>> 11) & 0x7fffffff);
|
||||||
|
String product = "Product-" + (a % 500);
|
||||||
|
int qty = 1 + b % 20;
|
||||||
|
BigDecimal price = BigDecimal.valueOf(99 + (a % 99_900), 2);
|
||||||
|
return new SaleRow(id,
|
||||||
|
BASE.plusDays(b % 365),
|
||||||
|
REGIONS[a % REGIONS.length],
|
||||||
|
product,
|
||||||
|
qty,
|
||||||
|
price,
|
||||||
|
STATUSES[b % STATUSES.length],
|
||||||
|
"INV/2025-26/" + String.format("%07d", id) + " dispatched via hub " + (b % 97));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package com.ankurm.excelexport;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.util.Iterator;
|
||||||
|
|
||||||
|
import org.apache.poi.ss.usermodel.BorderStyle;
|
||||||
|
import org.apache.poi.ss.usermodel.Cell;
|
||||||
|
import org.apache.poi.ss.usermodel.CellStyle;
|
||||||
|
import org.apache.poi.ss.usermodel.FillPatternType;
|
||||||
|
import org.apache.poi.ss.usermodel.Font;
|
||||||
|
import org.apache.poi.ss.usermodel.HorizontalAlignment;
|
||||||
|
import org.apache.poi.ss.usermodel.IndexedColors;
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
|
import org.apache.poi.ss.usermodel.Workbook;
|
||||||
|
import org.apache.poi.ss.util.CellRangeAddress;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes {@link SaleRow}s into any POI {@link Workbook}. The same code drives {@code XSSFWorkbook} (everything
|
||||||
|
* in heap) and {@code SXSSFWorkbook} (a sliding window of rows in heap, the rest on disk), so the only thing
|
||||||
|
* that differs between the two measured endpoints is the constructor call.
|
||||||
|
*
|
||||||
|
* <p>Explained in the post section "Styling and the 64,000 cell-style limit".
|
||||||
|
*/
|
||||||
|
public final class XlsxExporter {
|
||||||
|
|
||||||
|
/** Excel's hard limit per worksheet, header row included (rows 0..1,048,575). */
|
||||||
|
public static final int EXCEL_MAX_ROWS = 1_048_576;
|
||||||
|
|
||||||
|
private static final String[] HEADERS = {"Order id", "Order date", "Region", "Product", "Qty", "Unit price", "Amount", "Status", "Note"};
|
||||||
|
private static final int[] WIDTH_CHARS = {12, 13, 12, 14, 7, 14, 16, 11, 42};
|
||||||
|
|
||||||
|
/** Lakh/crore grouping (12,34,567.89) as an Excel number format: two conditions plus a default section. */
|
||||||
|
static final String INR_FORMAT = "[>=10000000]##\\,##\\,##\\,##0.00;[>=100000]##\\,##\\,##0.00;##,##0.00";
|
||||||
|
|
||||||
|
/** The styles are created once per workbook. A style per cell would hit Excel's limit of about 64,000 styles. */
|
||||||
|
private record Styles(CellStyle header, CellStyle date, CellStyle integer, CellStyle money) { }
|
||||||
|
|
||||||
|
private XlsxExporter() { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param maxRowsPerSheet rows per sheet including the header row; {@link #EXCEL_MAX_ROWS} in production,
|
||||||
|
* smaller in tests so rolling can be seen without a million rows
|
||||||
|
* @return the number of sheets written
|
||||||
|
*/
|
||||||
|
public static int write(Workbook wb, Iterator<SaleRow> rows, int maxRowsPerSheet, OutputStream out) throws IOException {
|
||||||
|
if (maxRowsPerSheet < 2 || maxRowsPerSheet > EXCEL_MAX_ROWS) {
|
||||||
|
throw new IllegalArgumentException("maxRowsPerSheet must be 2.." + EXCEL_MAX_ROWS);
|
||||||
|
}
|
||||||
|
Styles st = styles(wb);
|
||||||
|
int sheets = 0;
|
||||||
|
Sheet sheet = null;
|
||||||
|
int rowIdx = 0;
|
||||||
|
while (rows.hasNext()) {
|
||||||
|
if (sheet == null || rowIdx >= maxRowsPerSheet) {
|
||||||
|
sheet = newSheet(wb, ++sheets, st);
|
||||||
|
rowIdx = 1; // row 0 is the header
|
||||||
|
}
|
||||||
|
SaleRow r = rows.next();
|
||||||
|
Row row = sheet.createRow(rowIdx++);
|
||||||
|
row.createCell(0).setCellValue(r.id());
|
||||||
|
Cell d = row.createCell(1);
|
||||||
|
d.setCellValue(r.orderDate());
|
||||||
|
d.setCellStyle(st.date());
|
||||||
|
row.createCell(2).setCellValue(r.region());
|
||||||
|
row.createCell(3).setCellValue(r.product());
|
||||||
|
Cell q = row.createCell(4);
|
||||||
|
q.setCellValue(r.quantity());
|
||||||
|
q.setCellStyle(st.integer());
|
||||||
|
Cell p = row.createCell(5);
|
||||||
|
p.setCellValue(r.unitPrice().doubleValue());
|
||||||
|
p.setCellStyle(st.money());
|
||||||
|
Cell a = row.createCell(6);
|
||||||
|
a.setCellValue(r.amount().doubleValue());
|
||||||
|
a.setCellStyle(st.money());
|
||||||
|
row.createCell(7).setCellValue(r.status());
|
||||||
|
row.createCell(8).setCellValue(r.note());
|
||||||
|
}
|
||||||
|
if (sheets == 0) {
|
||||||
|
newSheet(wb, 1, st); // an empty report still opens in Excel
|
||||||
|
sheets = 1;
|
||||||
|
}
|
||||||
|
wb.write(out);
|
||||||
|
return sheets;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Sheet newSheet(Workbook wb, int n, Styles st) {
|
||||||
|
Sheet sheet = wb.createSheet(n == 1 ? "Sales" : "Sales " + n);
|
||||||
|
for (int c = 0; c < WIDTH_CHARS.length; c++) {
|
||||||
|
sheet.setColumnWidth(c, WIDTH_CHARS[c] * 256); // fixed widths: autoSizeColumn on SXSSF would need every row kept
|
||||||
|
}
|
||||||
|
Row h = sheet.createRow(0);
|
||||||
|
for (int c = 0; c < HEADERS.length; c++) {
|
||||||
|
Cell cell = h.createCell(c);
|
||||||
|
cell.setCellValue(HEADERS[c]);
|
||||||
|
cell.setCellStyle(st.header());
|
||||||
|
}
|
||||||
|
sheet.createFreezePane(0, 1);
|
||||||
|
sheet.setAutoFilter(new CellRangeAddress(0, 0, 0, HEADERS.length - 1));
|
||||||
|
return sheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Styles styles(Workbook wb) {
|
||||||
|
var df = wb.createDataFormat();
|
||||||
|
|
||||||
|
Font bold = wb.createFont();
|
||||||
|
bold.setBold(true);
|
||||||
|
bold.setColor(IndexedColors.WHITE.getIndex());
|
||||||
|
CellStyle header = wb.createCellStyle();
|
||||||
|
header.setFont(bold);
|
||||||
|
header.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex());
|
||||||
|
header.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||||
|
header.setAlignment(HorizontalAlignment.CENTER);
|
||||||
|
header.setBorderBottom(BorderStyle.THIN);
|
||||||
|
|
||||||
|
CellStyle date = wb.createCellStyle();
|
||||||
|
date.setDataFormat(df.getFormat("dd-mmm-yyyy"));
|
||||||
|
date.setAlignment(HorizontalAlignment.CENTER);
|
||||||
|
|
||||||
|
CellStyle integer = wb.createCellStyle();
|
||||||
|
integer.setDataFormat(df.getFormat("#,##0"));
|
||||||
|
|
||||||
|
CellStyle money = wb.createCellStyle();
|
||||||
|
money.setDataFormat(df.getFormat(INR_FORMAT));
|
||||||
|
return new Styles(header, date, integer, money);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Streaming downloads can run for minutes on a slow link; the default async timeout would cut them off.
|
||||||
|
spring.mvc.async.request-timeout=10m
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.ankurm.excelexport;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.io.OutputStreamWriter;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Iterator;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import com.opencsv.CSVWriter;
|
||||||
|
|
||||||
|
@org.junit.jupiter.api.TestMethodOrder(org.junit.jupiter.api.MethodOrderer.MethodName.class)
|
||||||
|
class CsvTest {
|
||||||
|
|
||||||
|
static final String F = "03-csv.txt";
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void clean() {
|
||||||
|
Out.reset(F);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void a_shapeBomAndQuoting() throws Exception {
|
||||||
|
var bos = new ByteArrayOutputStream();
|
||||||
|
long n = CsvExporter.write(SaleSource.rows(3), bos, true);
|
||||||
|
byte[] raw = bos.toByteArray();
|
||||||
|
String text = new String(raw, StandardCharsets.UTF_8);
|
||||||
|
Out.say(F, "first bytes: %02X %02X %02X (UTF-8 BOM so Excel reads UTF-8)".formatted(raw[0], raw[1], raw[2]));
|
||||||
|
Out.say(F, "rows written: " + n);
|
||||||
|
text.substring(1).lines().forEach(l -> Out.say(F, " " + l));
|
||||||
|
assertThat(raw[0]).isEqualTo((byte) 0xEF);
|
||||||
|
assertThat(n).isEqualTo(3);
|
||||||
|
|
||||||
|
var plain = new ByteArrayOutputStream();
|
||||||
|
CsvExporter.write(SaleSource.rows(0), plain, false);
|
||||||
|
assertThat(plain.toByteArray()[0]).isEqualTo((byte) 'O');
|
||||||
|
|
||||||
|
var tricky = new ByteArrayOutputStream();
|
||||||
|
var w = new CSVWriter(new OutputStreamWriter(tricky, StandardCharsets.UTF_8));
|
||||||
|
w.writeNext(new String[] {"Pune, MH", "said \"hi\"", "line1\nline2"}, false);
|
||||||
|
w.flush();
|
||||||
|
Out.say(F, "");
|
||||||
|
Out.say(F, "comma, quote and newline inside fields: " + tricky.toString(StandardCharsets.UTF_8).replace("\n", "\\n"));
|
||||||
|
assertThat(tricky.toString(StandardCharsets.UTF_8)).startsWith("\"Pune, MH\",\"said \"\"hi\"\"\",\"line1\\nline2\"".replace("\\n", "\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void b_formulaInjectionIsNeutralised() {
|
||||||
|
Out.say(F, "");
|
||||||
|
for (String s : new String[] {"=HYPERLINK(\"http://x\",\"pay\")", "+91 98200 00000", "-2+3", "@SUM(A1)", "Mumbai"}) {
|
||||||
|
Out.say(F, " %-32s -> %s".formatted(s, CsvExporter.safe(s)));
|
||||||
|
}
|
||||||
|
assertThat(CsvExporter.safe("=1+1")).isEqualTo("'=1+1");
|
||||||
|
assertThat(CsvExporter.safe("Mumbai")).isEqualTo("Mumbai");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An OutputStream that fails after a few kilobytes, like a client that closed the browser tab. */
|
||||||
|
static OutputStream breaksAfter(int bytes) {
|
||||||
|
return new OutputStream() {
|
||||||
|
int n;
|
||||||
|
@Override public void write(int b) throws IOException {
|
||||||
|
if (++n > bytes) {
|
||||||
|
throw new IOException("Broken pipe");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void c_openCsvSwallowsWriteErrors() throws Exception {
|
||||||
|
Out.say(F, "");
|
||||||
|
// 1. raw CSVWriter: 10,000 rows into a stream that breaks after 1,000 bytes
|
||||||
|
var w = new CSVWriter(new OutputStreamWriter(breaksAfter(1_000), StandardCharsets.UTF_8));
|
||||||
|
Iterator<SaleRow> it = SaleSource.rows(10_000);
|
||||||
|
int written = 0;
|
||||||
|
while (it.hasNext()) {
|
||||||
|
w.writeNext(new String[] {it.next().note()}, false);
|
||||||
|
written++;
|
||||||
|
}
|
||||||
|
Out.say(F, "raw CSVWriter into a broken stream: writeNext called %,d times, none threw".formatted(written));
|
||||||
|
Out.say(F, " checkError() = %s, getException() = %s".formatted(w.checkError(), w.getException()));
|
||||||
|
assertThat(written).isEqualTo(10_000);
|
||||||
|
assertThat(w.checkError()).isTrue();
|
||||||
|
|
||||||
|
// 2. our exporter checks and throws
|
||||||
|
assertThatThrownBy(() -> CsvExporter.write(SaleSource.rows(10_000), breaksAfter(1_000), false))
|
||||||
|
.isInstanceOf(IOException.class)
|
||||||
|
.satisfies(e -> Out.say(F, "CsvExporter into the same stream: " + e.getClass().getSimpleName() + ": " + e.getMessage()
|
||||||
|
+ " (cause: " + e.getCause() + ")"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.ankurm.excelexport;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||||
|
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
|
class EndpointTest {
|
||||||
|
|
||||||
|
static final String F = "04-endpoints.txt";
|
||||||
|
static final HttpClient HTTP = HttpClient.newHttpClient();
|
||||||
|
|
||||||
|
@LocalServerPort int port;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void clean() {
|
||||||
|
Out.reset(F);
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponse<byte[]> get(String path) throws Exception {
|
||||||
|
return HTTP.send(HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)).build(),
|
||||||
|
HttpResponse.BodyHandlers.ofByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void headersAndBodies() throws Exception {
|
||||||
|
for (String p : new String[] {"/export/xssf?rows=1500", "/export/sxssf?rows=1500", "/export/csv?rows=1500"}) {
|
||||||
|
HttpResponse<byte[]> r = get(p);
|
||||||
|
Out.say(F, "GET %s -> %d, %s, %s, content-length header: %s, %,d bytes".formatted(p, r.statusCode(),
|
||||||
|
r.headers().firstValue("Content-Type").orElse("-"),
|
||||||
|
r.headers().firstValue("Content-Disposition").orElse("-"),
|
||||||
|
r.headers().firstValue("Content-Length").orElse("none (chunked: " + r.headers().firstValue("Transfer-Encoding").orElse("-") + ")"),
|
||||||
|
r.body().length));
|
||||||
|
assertThat(r.statusCode()).isEqualTo(200);
|
||||||
|
if (p.contains("xssf")) {
|
||||||
|
try (var wb = new XSSFWorkbook(new ByteArrayInputStream(r.body()))) {
|
||||||
|
assertThat(wb.getSheetAt(0).getLastRowNum()).isEqualTo(1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.ankurm.excelexport;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.UncheckedIOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
|
||||||
|
/** Writes the transcript files under output/ that the post quotes. The tests assert the same numbers they print. */
|
||||||
|
final class Out {
|
||||||
|
|
||||||
|
static final Path DIR = Path.of("output");
|
||||||
|
|
||||||
|
private Out() { }
|
||||||
|
|
||||||
|
static void reset(String file) {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(DIR);
|
||||||
|
Files.deleteIfExists(DIR.resolve(file));
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UncheckedIOException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void say(String file, String line) {
|
||||||
|
System.out.println(line);
|
||||||
|
try {
|
||||||
|
Files.writeString(DIR.resolve(file), line + System.lineSeparator(),
|
||||||
|
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UncheckedIOException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.ankurm.excelexport;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import org.apache.poi.util.DefaultTempFileCreationStrategy;
|
||||||
|
import org.apache.poi.util.TempFile;
|
||||||
|
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
/** SXSSF spills rows to temp files. This shows when they are removed and what compressTempFiles changes. */
|
||||||
|
@org.junit.jupiter.api.TestMethodOrder(org.junit.jupiter.api.MethodOrderer.MethodName.class)
|
||||||
|
class TempFileTest {
|
||||||
|
|
||||||
|
static final String F = "02-temp-files.txt";
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void clean() {
|
||||||
|
Out.reset(F);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String[] list(Path dir) {
|
||||||
|
String[] names = dir.toFile().list();
|
||||||
|
Arrays.sort(names);
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
static long sizeOf(Path dir) {
|
||||||
|
long t = 0;
|
||||||
|
for (File f : dir.toFile().listFiles()) {
|
||||||
|
t += f.length();
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void a_whenTheTempFilesGoAway(@TempDir Path dir) throws Exception {
|
||||||
|
TempFile.setTempFileCreationStrategy(new DefaultTempFileCreationStrategy(dir.toFile()));
|
||||||
|
|
||||||
|
// 1. exported, written, never disposed
|
||||||
|
SXSSFWorkbook leaked = new SXSSFWorkbook(100);
|
||||||
|
XlsxExporter.write(leaked, SaleSource.rows(20_000), XlsxExporter.EXCEL_MAX_ROWS, new ByteArrayOutputStream());
|
||||||
|
Out.say(F, "after write(), no dispose(): %d temp file(s) left: %s".formatted(list(dir).length, Arrays.toString(list(dir))));
|
||||||
|
assertThat(list(dir)).hasSize(1);
|
||||||
|
|
||||||
|
// 2. close() alone
|
||||||
|
leaked.close();
|
||||||
|
Out.say(F, "after close() only: %d temp file(s) left".formatted(list(dir).length));
|
||||||
|
int afterClose = list(dir).length;
|
||||||
|
|
||||||
|
// 3. dispose()
|
||||||
|
leaked.dispose();
|
||||||
|
Out.say(F, "after dispose(): %d temp file(s) left".formatted(list(dir).length));
|
||||||
|
assertThat(list(dir)).isEmpty();
|
||||||
|
assertThat(afterClose).isIn(0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void b_compressTempFilesShrinksTheSpill(@TempDir Path dir) throws Exception {
|
||||||
|
TempFile.setTempFileCreationStrategy(new DefaultTempFileCreationStrategy(dir.toFile()));
|
||||||
|
long[] sizes = new long[2];
|
||||||
|
for (int i = 0; i < 2; i++) {
|
||||||
|
SXSSFWorkbook wb = new SXSSFWorkbook(100);
|
||||||
|
wb.setCompressTempFiles(i == 1);
|
||||||
|
try {
|
||||||
|
XlsxExporter.write(wb, SaleSource.rows(50_000), XlsxExporter.EXCEL_MAX_ROWS, new ByteArrayOutputStream());
|
||||||
|
sizes[i] = sizeOf(dir); // measured after write(), before dispose()
|
||||||
|
} finally {
|
||||||
|
wb.dispose();
|
||||||
|
wb.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Out.say(F, "");
|
||||||
|
Out.say(F, "temp file size after writing 50,000 rows (9 columns), before dispose():");
|
||||||
|
Out.say(F, " compressTempFiles=false: %,d bytes".formatted(sizes[0]));
|
||||||
|
Out.say(F, " compressTempFiles=true: %,d bytes".formatted(sizes[1]));
|
||||||
|
assertThat(sizes[1]).isLessThan(sizes[0]);
|
||||||
|
assertThat(Files.list(dir).count()).isZero();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.ankurm.excelexport;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
|
||||||
|
import org.apache.poi.ss.usermodel.DataFormatter;
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
|
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFSheet;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
@org.junit.jupiter.api.TestMethodOrder(org.junit.jupiter.api.MethodOrderer.MethodName.class)
|
||||||
|
class XlsxStructureTest {
|
||||||
|
|
||||||
|
static final String F = "01-xlsx-structure.txt";
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void clean() {
|
||||||
|
Out.reset(F);
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] xssf(int rows, int limit) throws Exception {
|
||||||
|
var bos = new ByteArrayOutputStream();
|
||||||
|
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||||
|
XlsxExporter.write(wb, SaleSource.rows(rows), limit, bos);
|
||||||
|
}
|
||||||
|
return bos.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] sxssf(int rows, int limit) throws Exception {
|
||||||
|
var bos = new ByteArrayOutputStream();
|
||||||
|
SXSSFWorkbook wb = new SXSSFWorkbook(100);
|
||||||
|
try {
|
||||||
|
XlsxExporter.write(wb, SaleSource.rows(rows), limit, bos);
|
||||||
|
} finally {
|
||||||
|
wb.dispose();
|
||||||
|
wb.close();
|
||||||
|
}
|
||||||
|
return bos.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void a_sheetsRollAndBothWorkbookTypesAgree() throws Exception {
|
||||||
|
// 2,500 data rows with a pretend limit of 1,000 rows per sheet (header included) = 999 data rows per sheet.
|
||||||
|
byte[] a = xssf(2_500, 1_000);
|
||||||
|
byte[] b = sxssf(2_500, 1_000);
|
||||||
|
DataFormatter fmt = new DataFormatter();
|
||||||
|
try (var x = new XSSFWorkbook(new ByteArrayInputStream(a)); var s = new XSSFWorkbook(new ByteArrayInputStream(b))) {
|
||||||
|
Out.say(F, "sheetLimit=1000 (header included), 2500 data rows");
|
||||||
|
assertThat(x.getNumberOfSheets()).isEqualTo(3).isEqualTo(s.getNumberOfSheets());
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
Sheet sx = x.getSheetAt(i);
|
||||||
|
Sheet ss = s.getSheetAt(i);
|
||||||
|
Out.say(F, " sheet %d \"%s\": last row index %d, first data cell %s".formatted(
|
||||||
|
i + 1, sx.getSheetName(), sx.getLastRowNum(), fmt.formatCellValue(sx.getRow(1).getCell(0))));
|
||||||
|
assertThat(ss.getSheetName()).isEqualTo(sx.getSheetName());
|
||||||
|
assertThat(ss.getLastRowNum()).isEqualTo(sx.getLastRowNum());
|
||||||
|
for (int r = 0; r <= sx.getLastRowNum(); r += 97) {
|
||||||
|
for (int c = 0; c < 9; c++) {
|
||||||
|
assertThat(fmt.formatCellValue(ss.getRow(r).getCell(c)))
|
||||||
|
.isEqualTo(fmt.formatCellValue(sx.getRow(r).getCell(c)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertThat(x.getSheetAt(0).getLastRowNum()).isEqualTo(999);
|
||||||
|
assertThat(x.getSheetAt(2).getLastRowNum()).isEqualTo(2_500 - 2 * 999);
|
||||||
|
Out.say(F, "XSSF and SXSSF files: same sheets, same formatted cell values on every 97th row");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void b_stylingSurvivesAndStyleCountStaysSmall() throws Exception {
|
||||||
|
byte[] b = sxssf(5_000, XlsxExporter.EXCEL_MAX_ROWS);
|
||||||
|
DataFormatter fmt = new DataFormatter();
|
||||||
|
try (var wb = new XSSFWorkbook(new ByteArrayInputStream(b))) {
|
||||||
|
XSSFSheet sh = wb.getSheetAt(0);
|
||||||
|
Row h = sh.getRow(0);
|
||||||
|
Out.say(F, "");
|
||||||
|
Out.say(F, "styling of a 5,000-row SXSSF file, read back with XSSF");
|
||||||
|
Out.say(F, " header cell A1 text=%s bold=%s".formatted(h.getCell(0).getStringCellValue(),
|
||||||
|
wb.getFontAt(h.getCell(0).getCellStyle().getFontIndex()).getBold()));
|
||||||
|
Out.say(F, " freeze pane: row split at %d".formatted(sh.getPaneInformation().getHorizontalSplitPosition()));
|
||||||
|
Out.say(F, " auto filter: " + sh.getCTWorksheet().getAutoFilter().getRef());
|
||||||
|
Out.say(F, " cell styles in the workbook: %d (for 5,000 rows x 9 cells)".formatted(wb.getNumCellStyles()));
|
||||||
|
Out.say(F, " B2 as Excel shows it: " + fmt.formatCellValue(sh.getRow(1).getCell(1)));
|
||||||
|
Out.say(F, " amount number format: " + sh.getRow(1).getCell(6).getCellStyle().getDataFormatString());
|
||||||
|
assertThat(h.getCell(0).getCellStyle().getFontIndex()).isNotZero();
|
||||||
|
assertThat(sh.getPaneInformation().getHorizontalSplitPosition()).isEqualTo((short) 1);
|
||||||
|
assertThat(wb.getNumCellStyles()).isLessThan(10);
|
||||||
|
assertThat(fmt.formatCellValue(sh.getRow(1).getCell(1))).matches("\\d{2}-\\w{3}-2025|\\d{2}-\\w{3}-2026");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void c_excelRowLimitIsReal() throws Exception {
|
||||||
|
Out.say(F, "");
|
||||||
|
Out.say(F, "the sheet limit");
|
||||||
|
try (SXSSFWorkbook wb = new SXSSFWorkbook(100)) {
|
||||||
|
Sheet s = wb.createSheet("limit");
|
||||||
|
s.createRow(XlsxExporter.EXCEL_MAX_ROWS - 1); // last legal row index 1,048,575
|
||||||
|
Out.say(F, " createRow(1048575) ok");
|
||||||
|
assertThatThrownBy(() -> s.createRow(XlsxExporter.EXCEL_MAX_ROWS))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.satisfies(e -> Out.say(F, " createRow(1048576) -> " + e.getClass().getSimpleName() + ": " + e.getMessage()));
|
||||||
|
}
|
||||||
|
try (SXSSFWorkbook wb = new SXSSFWorkbook(100)) {
|
||||||
|
assertThatThrownBy(() -> XlsxExporter.write(wb, SaleSource.rows(1), XlsxExporter.EXCEL_MAX_ROWS + 1, new ByteArrayOutputStream()))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void d_indianGroupingFormatRenders() throws Exception {
|
||||||
|
Out.say(F, "");
|
||||||
|
Out.say(F, "lakh/crore number format, rendered by POI's DataFormatter (not by Excel)");
|
||||||
|
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||||
|
var style = wb.createCellStyle();
|
||||||
|
style.setDataFormat(wb.createDataFormat().getFormat(XlsxExporter.INR_FORMAT));
|
||||||
|
Sheet sh = wb.createSheet("fmt");
|
||||||
|
DataFormatter fmt = new DataFormatter();
|
||||||
|
double[] values = {999.5, 99_999.5, 123_456.78, 1_234_567.89, 12_345_678.9, 123_456_789.1};
|
||||||
|
String[] expected = {"999.50", "99,999.50", "1,23,456.78", "12,34,567.89", "1,23,45,678.90", "12,34,56,789.10"};
|
||||||
|
for (int i = 0; i < values.length; i++) {
|
||||||
|
var c = sh.createRow(i).createCell(0);
|
||||||
|
c.setCellValue(values[i]);
|
||||||
|
c.setCellStyle(style);
|
||||||
|
String shown = fmt.formatCellValue(c);
|
||||||
|
Out.say(F, " %,.2f -> %s".formatted(values[i], shown));
|
||||||
|
assertThat(shown).isEqualTo(expected[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user