dasmig-core 1.2.0
Shared random engine, distributions and data tiers for the dasmig generator libraries
Loading...
Searching...
No Matches
sinks.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: MIT
2// Copyright (c) 2020-2026 Diego Dasso Migotto
3
4#ifndef DASMIG_CORE_SINKS_HPP
5#define DASMIG_CORE_SINKS_HPP
6
7#include <concepts>
8#include <cstddef>
9#include <cstdint>
10#include <ostream>
11#include <stdexcept>
12#include <string>
13#include <string_view>
14#include <unordered_map>
15#include <utility>
16#include <vector>
17
18/// @file sinks.hpp
19/// @brief Writers for batches: JSON Lines, and CSV flattened from a result's
20/// JSON. Both can be given to for_each_batch() as the sink.
21/// @author Diego Dasso Migotto (diegomigotto at hotmail dot com)
22
23namespace dasmig::core
24{
25
26namespace detail
27{
28
30{
31 public:
32 explicit json_flattener(std::string_view text) : _text{text} {}
33
34 std::vector<std::pair<std::string, std::string>> run()
35 {
36 std::vector<std::pair<std::string, std::string>> cells;
37 skip_space();
38 value("", cells);
39 skip_space();
40 if (_pos != _text.size()) fail("unexpected text after the value");
41 return cells;
42 }
43
44 private:
45 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters)
46 static std::string child_path(const std::string& path, const std::string& key)
47 {
48 if (path.empty()) return key;
49 std::string out = path;
50 out += '.';
51 out += key;
52 return out;
53 }
54
55 [[noreturn]] void fail(const char* what) const
56 {
57 throw std::invalid_argument("flatten_json: " + std::string{what} + " at offset " +
58 std::to_string(_pos));
59 }
60
61 void skip_space()
62 {
63 while (_pos < _text.size() &&
64 (_text[_pos] == ' ' || _text[_pos] == '\t' || _text[_pos] == '\n' || _text[_pos] == '\r'))
65 {
66 ++_pos;
67 }
68 }
69
70 [[nodiscard]] char peek() const
71 {
72 if (_pos >= _text.size()) fail("unexpected end");
73 return _text[_pos];
74 }
75
76 static void append_utf8(std::string& out, std::uint32_t cp)
77 {
78 if (cp < 0x80U)
79 {
80 out += static_cast<char>(cp);
81 }
82 else if (cp < 0x800U)
83 {
84 out += static_cast<char>(0xC0U | (cp >> 6U));
85 out += static_cast<char>(0x80U | (cp & 0x3FU));
86 }
87 else if (cp < 0x10000U)
88 {
89 out += static_cast<char>(0xE0U | (cp >> 12U));
90 out += static_cast<char>(0x80U | ((cp >> 6U) & 0x3FU));
91 out += static_cast<char>(0x80U | (cp & 0x3FU));
92 }
93 else
94 {
95 out += static_cast<char>(0xF0U | (cp >> 18U));
96 out += static_cast<char>(0x80U | ((cp >> 12U) & 0x3FU));
97 out += static_cast<char>(0x80U | ((cp >> 6U) & 0x3FU));
98 out += static_cast<char>(0x80U | (cp & 0x3FU));
99 }
100 }
101
102 std::uint32_t hex4()
103 {
104 if (_pos + 4 > _text.size()) fail("short \\u escape");
105 std::uint32_t v = 0;
106 for (int i = 0; i < 4; ++i)
107 {
108 const char c = _text[_pos++];
109 v <<= 4U;
110 if (c >= '0' && c <= '9') v |= static_cast<std::uint32_t>(c - '0');
111 else if (c >= 'a' && c <= 'f') v |= static_cast<std::uint32_t>(c - 'a' + 10);
112 else if (c >= 'A' && c <= 'F') v |= static_cast<std::uint32_t>(c - 'A' + 10);
113 else fail("bad \\u escape");
114 }
115 return v;
116 }
117
118 std::string string_value()
119 {
120 if (peek() != '"') fail("expected a string");
121 ++_pos;
122 std::string out;
123 for (;;)
124 {
125 const char c = peek();
126 ++_pos;
127 if (c == '"') return out;
128 if (c != '\\')
129 {
130 out += c;
131 continue;
132 }
133 const char e = peek();
134 ++_pos;
135 switch (e)
136 {
137 case '"': out += '"'; break;
138 case '\\': out += '\\'; break;
139 case '/': out += '/'; break;
140 case 'b': out += '\b'; break;
141 case 'f': out += '\f'; break;
142 case 'n': out += '\n'; break;
143 case 'r': out += '\r'; break;
144 case 't': out += '\t'; break;
145 case 'u':
146 {
147 std::uint32_t cp = hex4();
148 if (cp >= 0xD800U && cp <= 0xDBFFU && _pos + 1 < _text.size() &&
149 _text[_pos] == '\\' && _text[_pos + 1] == 'u')
150 {
151 _pos += 2;
152 const std::uint32_t lo = hex4();
153 if (lo < 0xDC00U || lo > 0xDFFFU) fail("bad surrogate pair");
154 cp = 0x10000U + ((cp - 0xD800U) << 10U) + (lo - 0xDC00U);
155 }
156 append_utf8(out, cp);
157 break;
158 }
159 default: fail("bad escape");
160 }
161 }
162 }
163
164 // Moves past one value of any kind without keeping it.
165 // NOLINTNEXTLINE(readability-function-cognitive-complexity, misc-no-recursion)
166 void skip_value()
167 {
168 skip_space();
169 const char c = peek();
170 if (c == '"')
171 {
172 (void)string_value();
173 }
174 else if (c == '{' || c == '[')
175 {
176 const char close = c == '{' ? '}' : ']';
177 ++_pos;
178 skip_space();
179 if (peek() == close)
180 {
181 ++_pos;
182 return;
183 }
184 for (;;)
185 {
186 if (c == '{')
187 {
188 skip_space();
189 (void)string_value();
190 skip_space();
191 if (peek() != ':') fail("expected ':'");
192 ++_pos;
193 }
194 skip_value();
195 skip_space();
196 const char sep = peek();
197 ++_pos;
198 if (sep == close) return;
199 if (sep != ',') fail("expected ',' or the end of the container");
200 }
201 }
202 else
203 {
204 const std::size_t start = _pos;
205 while (_pos < _text.size() && _text[_pos] != ',' && _text[_pos] != '}' &&
206 _text[_pos] != ']' && _text[_pos] != ' ' && _text[_pos] != '\n' &&
207 _text[_pos] != '\r' && _text[_pos] != '\t')
208 {
209 ++_pos;
210 }
211 if (_pos == start) fail("expected a value");
212 }
213 }
214
215 // NOLINTNEXTLINE(readability-function-cognitive-complexity, misc-no-recursion)
216 void value(const std::string& path, std::vector<std::pair<std::string, std::string>>& cells)
217 {
218 const char c = peek();
219 if (c == '{')
220 {
221 const std::size_t start = _pos;
222 ++_pos;
223 skip_space();
224 if (peek() == '}')
225 {
226 ++_pos;
227 if (!path.empty()) cells.emplace_back(path, std::string{_text.substr(start, 2)});
228 return;
229 }
230 for (;;)
231 {
232 skip_space();
233 const std::string key = string_value();
234 skip_space();
235 if (peek() != ':') fail("expected ':'");
236 ++_pos;
237 skip_space();
238 value(child_path(path, key), cells);
239 skip_space();
240 const char sep = peek();
241 ++_pos;
242 if (sep == '}') return;
243 if (sep != ',') fail("expected ',' or '}'");
244 }
245 }
246 if (c == '[')
247 {
248 const std::size_t start = _pos;
249 skip_value();
250 cells.emplace_back(path, std::string{_text.substr(start, _pos - start)});
251 return;
252 }
253 if (c == '"')
254 {
255 cells.emplace_back(path, string_value());
256 return;
257 }
258 const std::size_t start = _pos;
259 skip_value();
260 const std::string_view token = _text.substr(start, _pos - start);
261 cells.emplace_back(path, token == "null" ? std::string{} : std::string{token});
262 }
263
264 std::string_view _text;
265 std::size_t _pos{0};
266};
267
268} // namespace detail
269
270/// @brief Flatten a JSON document into dotted columns.
271///
272/// An object's members become `parent.child` cells, strings are unescaped,
273/// numbers and booleans keep their text, `null` is an empty cell, and an array
274/// (or an empty nested object) is one cell holding its JSON text. A document
275/// that is not an object gives one cell with an empty name.
276///
277/// @param json A complete JSON document.
278/// @return The `(column, cell)` pairs in document order.
279/// @throws std::invalid_argument If @p json is not valid JSON.
280[[nodiscard]] inline std::vector<std::pair<std::string, std::string>> flatten_json(
281 std::string_view json)
282{
283 return detail::json_flattener{json}.run();
284}
285
286/// @brief Whether a result has `to_json()` returning text.
287template <typename T>
288concept json_result = requires(const T& item) {
289 { item.to_json() } -> std::convertible_to<std::string_view>;
290};
291
292/// @brief Writes one JSON document per line (JSON Lines / NDJSON).
293///
294/// Use it as the sink of for_each_batch(); results need `to_json()`.
295///
296/// @code
297/// dasmig::core::jsonl_writer out{std::cout};
298/// gen.for_each_name(request, {.count = 1000}, 7, out);
299/// @endcode
301{
302 public:
303 /// @brief Write to @p out, which must outlive the writer.
304 /// @param out Destination stream.
305 explicit jsonl_writer(std::ostream& out) : _out{&out} {}
306
307 /// @brief Write one document as a line.
308 /// @param json Compact JSON; it must not contain a line break.
309 /// @throws std::invalid_argument If @p json contains a line break.
310 void write_line(std::string_view json)
311 {
312 if (json.find_first_of("\r\n") != std::string_view::npos)
313 {
314 throw std::invalid_argument("jsonl_writer: a JSON Lines record cannot contain a line break");
315 }
316 _out->write(json.data(), static_cast<std::streamsize>(json.size()));
317 _out->put('\n');
318 ++_lines;
319 }
320
321 /// @brief Sink call: write the item's `to_json()`.
322 /// @param item The result.
323 template <json_result T>
324 void operator()(std::uint64_t /*index*/, const T& item)
325 {
326 write_line(item.to_json());
327 }
328
329 /// @brief Lines written so far.
330 /// @return The count.
331 [[nodiscard]] std::size_t lines() const noexcept { return _lines; }
332
333 private:
334 std::ostream* _out;
335 std::size_t _lines{0};
336};
337
338/// @brief Writes results as CSV, one row each, with columns flattened from
339/// their JSON (see flatten_json()).
340///
341/// The header comes from the first record. Later records are matched to it by
342/// column name: a column the record lacks is empty, and a column the header
343/// lacks is dropped. Fields are quoted when they hold a comma, a quote or a
344/// line break; rows end with `\n`.
346{
347 public:
348 /// @brief Write to @p out, which must outlive the writer.
349 /// @param out Destination stream.
350 explicit csv_writer(std::ostream& out) : _out{&out} {}
351
352 /// @brief Write one record given as JSON.
353 /// @param json A JSON document.
354 /// @throws std::invalid_argument If @p json is not valid JSON.
355 void write_json(std::string_view json)
356 {
357 const auto cells = flatten_json(json);
358 if (!_header_written)
359 {
360 _columns.reserve(cells.size());
361 for (const auto& cell : cells) _columns.push_back(cell.first);
362 write_row(_columns);
363 _header_written = true;
364 }
365 std::vector<std::string> row(_columns.size());
366 bool in_order = cells.size() == _columns.size();
367 for (std::size_t i = 0; in_order && i < cells.size(); ++i)
368 {
369 in_order = cells[i].first == _columns[i];
370 }
371 if (in_order)
372 {
373 for (std::size_t i = 0; i < cells.size(); ++i) row[i] = cells[i].second;
374 }
375 else
376 {
377 std::unordered_map<std::string_view, std::size_t> position;
378 position.reserve(_columns.size());
379 for (std::size_t i = 0; i < _columns.size(); ++i) position.emplace(_columns[i], i);
380 for (const auto& cell : cells)
381 {
382 if (const auto it = position.find(cell.first); it != position.end())
383 {
384 row[it->second] = cell.second;
385 }
386 }
387 }
388 write_row(row);
389 ++_records;
390 }
391
392 /// @brief Sink call: write the item's `to_json()`.
393 /// @param item The result.
394 template <json_result T>
395 void operator()(std::uint64_t /*index*/, const T& item)
396 {
397 write_json(item.to_json());
398 }
399
400 /// @brief The columns, known after the first record.
401 /// @return The column names.
402 [[nodiscard]] const std::vector<std::string>& columns() const noexcept { return _columns; }
403
404 /// @brief Records written so far (not counting the header).
405 /// @return The count.
406 [[nodiscard]] std::size_t records() const noexcept { return _records; }
407
408 private:
409 void write_row(const std::vector<std::string>& fields)
410 {
411 for (std::size_t i = 0; i < fields.size(); ++i)
412 {
413 if (i > 0) _out->put(',');
414 write_field(fields[i]);
415 }
416 _out->put('\n');
417 }
418
419 void write_field(const std::string& field)
420 {
421 if (field.find_first_of(",\"\r\n") == std::string::npos)
422 {
423 _out->write(field.data(), static_cast<std::streamsize>(field.size()));
424 return;
425 }
426 _out->put('"');
427 for (const char c : field)
428 {
429 if (c == '"') _out->put('"');
430 _out->put(c);
431 }
432 _out->put('"');
433 }
434
435 std::ostream* _out;
436 std::vector<std::string> _columns;
437 bool _header_written{false};
438 std::size_t _records{0};
439};
440
441} // namespace dasmig::core
442
443#endif // DASMIG_CORE_SINKS_HPP
Writes results as CSV, one row each, with columns flattened from their JSON (see flatten_json()).
Definition sinks.hpp:346
std::size_t records() const noexcept
Records written so far (not counting the header).
Definition sinks.hpp:406
void write_json(std::string_view json)
Write one record given as JSON.
Definition sinks.hpp:355
csv_writer(std::ostream &out)
Write to out, which must outlive the writer.
Definition sinks.hpp:350
const std::vector< std::string > & columns() const noexcept
The columns, known after the first record.
Definition sinks.hpp:402
void operator()(std::uint64_t, const T &item)
Sink call: write the item's to_json().
Definition sinks.hpp:395
Writes one JSON document per line (JSON Lines / NDJSON).
Definition sinks.hpp:301
jsonl_writer(std::ostream &out)
Write to out, which must outlive the writer.
Definition sinks.hpp:305
void operator()(std::uint64_t, const T &item)
Sink call: write the item's to_json().
Definition sinks.hpp:324
std::size_t lines() const noexcept
Lines written so far.
Definition sinks.hpp:331
void write_line(std::string_view json)
Write one document as a line.
Definition sinks.hpp:310
Whether a result has to_json() returning text.
Definition sinks.hpp:288
std::vector< std::pair< std::string, std::string > > flatten_json(std::string_view json)
Flatten a JSON document into dotted columns.
Definition sinks.hpp:280