Why Do You Need a CSV Serializer at All?
When you are working with SAP CAP on BTP and need to export data — whether for a backup job, a data migration, or a scheduled report — you will eventually need to convert an array of plain JavaScript objects (the kind CAP's SELECT.from(...) returns) into a CSV file.
The instinct is to reach for a library. csv-stringify, fast-csv — there are dozens of them. But before adding any runtime dependency, it is worth asking: what exactly does a CSV serializer need to do?
RFC 4180 — the standard that defines CSV — is a short document. The rules are:
- Fields are separated by commas
- Records are separated by CRLF (
\r\n) - A header row with field names should appear as the first record
- Fields containing commas, double-quotes, or line breaks must be wrapped in double-quotes
- A double-quote appearing inside a quoted field must be escaped by doubling it (
"") - The last record in the file should have a trailing CRLF
What CAP Queries Return
When you run a SELECT.from(entityName) in CAP, the result is an array of plain JavaScript objects. Each object has one key per column, and the values can be any of these types depending on what is in your HANA schema:
CDS Type JavaScript value
String, UUID | string |
Integer, Decimal | number |
Boolean | boolean |
Date, DateTime, Timestamp | Date object |
LargeBinary | Buffer |
null / missing | null or undefined |
| Structured / complex type | plain object |
A CSV serializer that only handles strings will silently produce wrong output — or throw — the moment it encounters a Date, a Buffer, or a null. You need a serializer that knows about all of these.
The Full Implementation
"use strict";
const CRLF = "\r\n";
const _needsQuoting = (s) => /[",\r\n]/.test(s);
const _escape = (value) => {
if (value === null || value === undefined) return "";
let str;
if (value instanceof Date) str = value.toISOString();
else if (Buffer.isBuffer(value)) str = value.toString("base64");
else if (typeof value === "object") str = JSON.stringify(value);
else str = String(value);
if (_needsQuoting(str)) return `"${str.replace(/"/g, '""')}"`;
return str;
};
const toCSV = (rows, columns) => {
if (!Array.isArray(rows)) throw new TypeError("rows must be an array");
let cols = columns;
if (!cols || cols.length === 0) {
const colSet = new Set();
for (const row of rows) {
if (row && typeof row === "object") Object.keys(row).forEach(k => colSet.add(k));
}
cols = Array.from(colSet);
}
const lines = [];
lines.push(cols.map(_escape).join(",")); // header row
for (const row of rows) {
lines.push(cols.map(c => _escape(row?.[c])).join(","));
}
return lines.join(CRLF) + CRLF; // trailing CRLF per RFC 4180
};
module.exports = { toCSV };Breaking It Down
_needsQuoting
const _needsQuoting = (s) => /[",\r\n]/.test(s);A field value needs to be wrapped in double-quotes if it contains any of:
,— would be misread as a field separator"— would break the quoting boundary\ror\n— would be misread as a record separator
_escape
This is the heart of the serializer. It handles every type that can come out of a CAP query:
if (value === null || value === undefined) return "";Returns an empty string — not the literal text "null". An empty CSV field is the universally understood representation of a missing value.
if (value instanceof Date) str = value.toISOString();Converts to ISO 8601 (2026-07-29T00:00:00.000Z). Timezone-unambiguous and parseable by every spreadsheet and database tool.
else if (Buffer.isBuffer(value)) str = value.toString("base64");If a source entity itself has a LargeBinary column (e.g. a stored document), it needs to survive the CSV round-trip. Base64 is the standard way to represent binary data in a text format.
else if (typeof value === "object") str = JSON.stringify(value);Structured or complex types that CAP returns as nested objects are serialised to their JSON representation — recoverable and unambiguous.
else str = String(value);Numbers, booleans, UUIDs — all have clean string representations.
After converting to a string, quoting and double-quote escaping are applied if needed:
if (_needsQuoting(str)) return `"${str.replace(/"/g, '""')}"`;toCSV
const toCSV = (rows, columns) => { ... }Two parameters:
rows— the array of objects fromSELECT.from(...)columns— an optional explicit list of column names
Why pass columns explicitly?
If columns is omitted, the function derives the column list from the union of all keys across all rows. This works for ad-hoc use. But in a backup job where you are paging through millions of rows and writing multiple chunks, you must pass the column list explicitly — derived from the CDS model's element definitions once, before the loop starts.
If you let each chunk derive its own column list from Object.keys(row), two things can go wrong:
- JavaScript object key order is not guaranteed to be consistent across different
SELECTresponses - An empty page or a page with sparse rows could produce a shorter column list
Either case produces misaligned CSVs when the chunks are concatenated into a single file later. Passing columns explicitly guarantees that every chunk has identical headers in identical order.
The header row:
lines.push(cols.map(_escape).join(","));The first line is always the header. Column names go through _escape too — a column named "description" (with quotes) would otherwise break the file.
The trailing CRLF:
return lines.join(CRLF) + CRLF;RFC 4180 requires a CRLF after the last record. Without it, chunks concatenated end-to-end would have the last row of one chunk and the header of the next chunk on the same line.
Usage
The serializer is a single function call inside the chunk flush:
const { toCSV } = require("./csvSerializer");
const csv = toCSV(rows, columns);
const buf = Buffer.from(csv, "utf8");
await INSERT.into("myapp.TableBackups").entries({
content : buf,
sizeBytes: buf.length,
rowCount : rows.length,
// ...
});toCSV returns a plain string. Buffer.from(csv, "utf8") converts it to a Buffer for the LargeBinary BLOB insert. The buf.length gives you the exact byte count for the sizeBytes column — useful when inspecting backup sizes in HANA.
Why Not Use a Library?
For a bounded, well-specified problem like RFC 4180 CSV serialization, the implementation cost is low and the ownership benefits are real. The moment a library has a CVE, an unexpected major version bump, or a behaviour change in how it handles null values, you are debugging someone else's code. Here, there is nothing to debug that you did not write yourself.
For the full backup job implementation that uses this serializer — including chunked streaming reads, BTP Job Scheduler wiring, and mta.yaml memory tuning — refer to: Handling HANA Table Backups at Scale in SAP CAP on BTP
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.