export_kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +242 -0
- data/README.pl.md +113 -0
- data/lib/export_kit/active_record.rb +16 -0
- data/lib/export_kit/artifact.rb +22 -0
- data/lib/export_kit/document.rb +165 -0
- data/lib/export_kit/format_registry.rb +15 -0
- data/lib/export_kit/model_rows.rb +96 -0
- data/lib/export_kit/model_schema.rb +96 -0
- data/lib/export_kit/model_serializer.rb +265 -0
- data/lib/export_kit/output.rb +58 -0
- data/lib/export_kit/render.rb +187 -0
- data/lib/export_kit/renderers/base.rb +94 -0
- data/lib/export_kit/renderers/csv_archive.rb +108 -0
- data/lib/export_kit/renderers/json.rb +69 -0
- data/lib/export_kit/renderers/pdf.rb +127 -0
- data/lib/export_kit/result.rb +68 -0
- data/lib/export_kit/table.rb +41 -0
- data/lib/export_kit/validate_artifact.rb +296 -0
- data/lib/export_kit/version.rb +5 -0
- data/lib/export_kit.rb +27 -0
- metadata +134 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 401631ec4132efca4eb76e051db2e90bef4e3371610198ae45e311a4d11f446b
|
|
4
|
+
data.tar.gz: 9e1b1d4c5bf0fe15c7d0848c00903453548f52c58362eed1749235416ce852f7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 69afffa72dec01eea9973e2f2aefff6c7956562bcf4ce56f7b442717f79d02e624b593d74cee98cf825fded7060f7ff90c61fc5d762b5b3e3f97a17e667d3c26
|
|
7
|
+
data.tar.gz: 8e626eaecd333db03433bd5ec756922f2beed0796711fd6015bcdaadf233ab328f8f67a9dc58ab95871104ae4b3f0ccb0ba370ac1caf33581c0f37e71d2bf3e7
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-10-26
|
|
4
|
+
|
|
5
|
+
First public release.
|
|
6
|
+
|
|
7
|
+
- Renders hashes and explicit tables as JSON or CSV.
|
|
8
|
+
- Renders multi-section documents as JSON, ZIP archives of CSV tables, or PDF.
|
|
9
|
+
- Enforces a caller-provided output-size limit while data is written.
|
|
10
|
+
- Neutralizes spreadsheet formulas in CSV cells.
|
|
11
|
+
- Returns typed results for expected failures and removes partial temporary files.
|
|
12
|
+
- Verifies artifact digests and validates the structure of supported formats.
|
|
13
|
+
- Provides an optional Active Record adapter with explicit allow/deny schemas and keyset batching.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Łukasz Kerl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# ExportKit
|
|
2
|
+
|
|
3
|
+
Portable, size-bounded exports for Ruby applications.
|
|
4
|
+
|
|
5
|
+
ExportKit turns plain Ruby data, explicit tables or multi-section documents into JSON, CSV,
|
|
6
|
+
ZIP archives of CSV tables, and PDF. It streams rows, stops writing at a caller-provided byte
|
|
7
|
+
limit, neutralizes spreadsheet formulas, removes partial temporary files and can validate the
|
|
8
|
+
artifact it produced.
|
|
9
|
+
|
|
10
|
+
*(Dokumentacja po polsku: [README.pl.md](README.pl.md))*
|
|
11
|
+
|
|
12
|
+
## Why this exists
|
|
13
|
+
|
|
14
|
+
An export is more than `CSV.generate`: the caller must decide what may leave the application,
|
|
15
|
+
preserve a stable schema, avoid loading an unbounded dataset, prevent formula injection, clean up
|
|
16
|
+
temporary files and distinguish an expected refusal from a programming error. ExportKit owns those
|
|
17
|
+
format-neutral mechanics. The application still owns authorization and the business meaning of the
|
|
18
|
+
data.
|
|
19
|
+
|
|
20
|
+
The core does not load Rails or Active Record. The optional adapter is enabled explicitly.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem "export_kit", "~> 0.1.0"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
ExportKit requires Ruby 3.4 or newer.
|
|
29
|
+
|
|
30
|
+
## A single table
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
require "export_kit"
|
|
34
|
+
require "fileutils"
|
|
35
|
+
|
|
36
|
+
table = ExportKit::Table.new(
|
|
37
|
+
columns: %i[sku name price],
|
|
38
|
+
labels: { sku: "SKU", name: "Name", price: "Price" },
|
|
39
|
+
rows: [
|
|
40
|
+
{ sku: "LAMP-01", name: "Ceramic lamp", price: "149.00" },
|
|
41
|
+
{ sku: "CHAIR-02", name: "=HYPERLINK(...)", price: "320.00" }
|
|
42
|
+
]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
result = ExportKit.render(table, format: :csv, max_bytes: 5 * 1024**2)
|
|
46
|
+
|
|
47
|
+
if result.success?
|
|
48
|
+
artifact = result.value!
|
|
49
|
+
begin
|
|
50
|
+
FileUtils.copy_file(artifact.file.path, "products.csv")
|
|
51
|
+
ensure
|
|
52
|
+
artifact.close!
|
|
53
|
+
end
|
|
54
|
+
else
|
|
55
|
+
warn result.error
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Column order is explicit. Every row must contain exactly the declared keys. CSV headers use the
|
|
60
|
+
labels, while JSON keeps the stable column keys. Strings beginning with `=`, `+`, `-` or `@` are
|
|
61
|
+
prefixed with an apostrophe in CSV output to prevent spreadsheet formula execution.
|
|
62
|
+
|
|
63
|
+
`rows` may also be a callback, which avoids materializing the whole dataset:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
rows = lambda do |&emit|
|
|
67
|
+
source.each_batch { |batch| batch.each { |record| emit.call(record) } }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
table = ExportKit::Table.new(columns: %i[id name], rows: rows)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The source is consumed synchronously and once per render.
|
|
74
|
+
|
|
75
|
+
## Plain JSON
|
|
76
|
+
|
|
77
|
+
Plain hashes and arrays can be rendered directly as JSON:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
result = ExportKit.render(
|
|
81
|
+
{ product: { sku: "LAMP-01", tags: ["vintage"] } },
|
|
82
|
+
format: :json,
|
|
83
|
+
max_bytes: 1_000_000
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Supported values are hashes with string or symbol keys, arrays, strings, finite numbers, booleans
|
|
88
|
+
and `nil`. Nesting deeper than 100 levels is rejected.
|
|
89
|
+
|
|
90
|
+
## Multi-section documents
|
|
91
|
+
|
|
92
|
+
`ExportKit::Document` describes a versioned export independently of its output format. Each record
|
|
93
|
+
set is an explicit table backed by a row callback.
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
users = ExportKit::Document::RecordSet.new(
|
|
97
|
+
name: "users",
|
|
98
|
+
columns: %i[id email],
|
|
99
|
+
column_labels: { id: "ID", email: "Email" },
|
|
100
|
+
row_source: ->(&emit) { authorized_users.find_each { |user| emit.call(id: user.id, email: user.email) } }
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
people = ExportKit::Document::Section.new(
|
|
104
|
+
key: "people",
|
|
105
|
+
label: "People",
|
|
106
|
+
description: "Authorized user records",
|
|
107
|
+
metadata: {},
|
|
108
|
+
record_sets: [users]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
document = ExportKit::Document.new(
|
|
112
|
+
identifier: "account_export",
|
|
113
|
+
schema_version: 1,
|
|
114
|
+
title: "Account export",
|
|
115
|
+
metadata: { requested_by: { user_id: 42 } },
|
|
116
|
+
sections: [people]
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
result = ExportKit.render(document, format: :csv_zip, max_bytes: 20 * 1024**2)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Supported document formats:
|
|
123
|
+
|
|
124
|
+
| format | artifact | notes |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| `json` | `.json` | versioned envelope, manifest and section data |
|
|
127
|
+
| `csv_zip` | `.zip` | one UTF-8 CSV per non-empty record set plus `manifest.csv` |
|
|
128
|
+
| `pdf` | `.pdf` | human-readable A4 report; requires an explicit Unicode font |
|
|
129
|
+
|
|
130
|
+
For PDF, provide a font file owned by the consuming application:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
ExportKit.render(
|
|
134
|
+
document,
|
|
135
|
+
format: :pdf,
|
|
136
|
+
max_bytes: 20 * 1024**2,
|
|
137
|
+
font: Rails.root.join("app/assets/fonts/Inter-Regular.ttf").to_s,
|
|
138
|
+
bold_font: Rails.root.join("app/assets/fonts/Inter-Bold.ttf").to_s
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Active Record adapter
|
|
143
|
+
|
|
144
|
+
Load the adapter explicitly:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
require "export_kit/active_record"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
For a direct table, pass an authorized relation and a field allowlist:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
table = ExportKit.records(
|
|
154
|
+
policy_scope(Order).reorder(nil),
|
|
155
|
+
fields: %i[id number total_minor],
|
|
156
|
+
labels: { number: "Order", total_minor: "Total (minor units)" },
|
|
157
|
+
batch_size: 500
|
|
158
|
+
)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Models may define a complete export policy and use `fields: :all` in serializer definitions:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
class User < ApplicationRecord
|
|
165
|
+
include ExportKit::ModelSchema
|
|
166
|
+
|
|
167
|
+
export_schema do
|
|
168
|
+
allow :id, :email
|
|
169
|
+
timestamps
|
|
170
|
+
omit :password_digest, :reset_token, reason: "authentication secrets"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
User.validate_export_schema!
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The validation requires every database column to be either allowed or denied with a reason. This
|
|
178
|
+
is a schema ratchet, not authorization. The application must still provide the correct scoped
|
|
179
|
+
relation.
|
|
180
|
+
|
|
181
|
+
The batching adapter accepts an unordered, ungrouped relation without `offset` or `distinct`, and
|
|
182
|
+
requires a single primary key. It paginates by that primary key and respects an existing `limit`.
|
|
183
|
+
|
|
184
|
+
## Results and cleanup
|
|
185
|
+
|
|
186
|
+
Expected render failures are returned as `ExportKit::Result`:
|
|
187
|
+
|
|
188
|
+
| error | meaning |
|
|
189
|
+
|---|---|
|
|
190
|
+
| `export_format_unsupported` | no renderer exists for the requested format |
|
|
191
|
+
| `export_too_large` | the byte limit was exceeded |
|
|
192
|
+
| `export_render_failed` | input or renderer output was invalid |
|
|
193
|
+
|
|
194
|
+
`Result` provides `success?`, `failure?`, `value`, `value!`, `error`, `error_details`, `map`,
|
|
195
|
+
`map_error` and `and_then`. Safe error details may identify a section, record set, row and column;
|
|
196
|
+
they never contain the rejected value.
|
|
197
|
+
|
|
198
|
+
Programming errors from a custom renderer or row source propagate after temporary output is
|
|
199
|
+
removed. The caller owns a successful artifact and must call `close!` (or its alias `cleanup`) in
|
|
200
|
+
an `ensure` block.
|
|
201
|
+
|
|
202
|
+
## Validation
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
artifact.verify # digest and byte count
|
|
206
|
+
ExportKit.validate(artifact) # digest plus supported-format structure
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Validation detects accidental corruption and malformed manifests. It does not prove that the
|
|
210
|
+
application selected every required record or excluded every private field.
|
|
211
|
+
|
|
212
|
+
## Security boundaries
|
|
213
|
+
|
|
214
|
+
ExportKit provides bounded writes, CSV formula neutralization, explicit schemas, safe error
|
|
215
|
+
locations, temporary-file cleanup and structural validation. It deliberately does not provide:
|
|
216
|
+
|
|
217
|
+
- authentication or authorization;
|
|
218
|
+
- tenant or policy scoping;
|
|
219
|
+
- transactional snapshots across several queries;
|
|
220
|
+
- encryption, object storage, downloads or retention;
|
|
221
|
+
- privacy-law completeness checks;
|
|
222
|
+
- a safe sandbox for custom renderers.
|
|
223
|
+
|
|
224
|
+
Treat custom renderers as trusted application code. Do not expose artifact paths in logs or error
|
|
225
|
+
responses. Set `max_bytes` from an application-level policy and deliver artifacts through an
|
|
226
|
+
authorized channel.
|
|
227
|
+
|
|
228
|
+
## Development
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
bin/setup
|
|
232
|
+
bundle exec rake test
|
|
233
|
+
bundle exec rubocop
|
|
234
|
+
gem build export_kit.gemspec
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The test suite does not use the network. See [CONTRIBUTING.md](CONTRIBUTING.md),
|
|
238
|
+
[SECURITY.md](SECURITY.md) and [docs/releasing.md](docs/releasing.md).
|
|
239
|
+
|
|
240
|
+
## License
|
|
241
|
+
|
|
242
|
+
The gem is available under the [MIT License](LICENSE.txt).
|
data/README.pl.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# ExportKit
|
|
2
|
+
|
|
3
|
+
Przenośne eksporty Ruby z jawnym limitem rozmiaru.
|
|
4
|
+
|
|
5
|
+
ExportKit zamienia zwykłe dane Ruby, jawne tabele albo dokumenty z wieloma sekcjami na JSON, CSV,
|
|
6
|
+
archiwa ZIP z tabelami CSV oraz PDF. Strumieniuje wiersze, zatrzymuje zapis po przekroczeniu limitu,
|
|
7
|
+
neutralizuje formuły arkusza, sprząta częściowe pliki tymczasowe i potrafi sprawdzić wynikowy artefakt.
|
|
8
|
+
|
|
9
|
+
Pełna dokumentacja API i przykłady znajdują się w angielskim [README.md](README.md). Ten plik
|
|
10
|
+
podsumowuje kontrakt integracyjny po polsku.
|
|
11
|
+
|
|
12
|
+
## Instalacja
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
gem "export_kit", "~> 0.1.0"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Wymagany jest Ruby 3.4 lub nowszy. Rdzeń nie uruchamia Rails ani Active Record. Adapter modeli
|
|
19
|
+
włącza się jawnie przez `require "export_kit/active_record"`.
|
|
20
|
+
|
|
21
|
+
## Pojedyncza tabela
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require "export_kit"
|
|
25
|
+
|
|
26
|
+
table = ExportKit::Table.new(
|
|
27
|
+
columns: %i[number total],
|
|
28
|
+
labels: { number: "Numer", total: "Kwota" },
|
|
29
|
+
rows: [{ number: "GV-001", total: "149.00" }]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
result = ExportKit.render(table, format: :csv, max_bytes: 5 * 1024**2)
|
|
33
|
+
|
|
34
|
+
if result.success?
|
|
35
|
+
artifact = result.value!
|
|
36
|
+
begin
|
|
37
|
+
send_data artifact.file.read, type: artifact.content_type
|
|
38
|
+
ensure
|
|
39
|
+
artifact.close!
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Każdy wiersz musi mieć dokładnie zadeklarowane kolumny. Kolejność kolumn jest stabilna. Wartości
|
|
45
|
+
CSV zaczynające się od `=`, `+`, `-` albo `@` dostają apostrof, aby arkusz nie wykonał ich jako
|
|
46
|
+
formuły. `rows` może być callbackiem przyjmującym blok `emit`, dzięki czemu nie trzeba budować całej
|
|
47
|
+
tablicy w pamięci.
|
|
48
|
+
|
|
49
|
+
## Formaty
|
|
50
|
+
|
|
51
|
+
- zwykły Hash lub Array: `json`;
|
|
52
|
+
- `ExportKit::Table`: `json` albo `csv`;
|
|
53
|
+
- `ExportKit::Document`: `json`, `csv_zip` albo `pdf`.
|
|
54
|
+
|
|
55
|
+
Dokument składa się z wersjonowanego identyfikatora, metadanych, sekcji i nazwanych zestawów
|
|
56
|
+
rekordów. ZIP zawiera osobny CSV dla każdego niepustego zestawu oraz `manifest.csv`. PDF wymaga
|
|
57
|
+
jawnej ścieżki do fontu Unicode dostarczonego przez aplikację.
|
|
58
|
+
|
|
59
|
+
## Wynik i sprzątanie
|
|
60
|
+
|
|
61
|
+
Oczekiwane odmowy nie rzucają wyjątku:
|
|
62
|
+
|
|
63
|
+
- `export_format_unsupported` — nieobsługiwany format;
|
|
64
|
+
- `export_too_large` — przekroczony limit bajtów;
|
|
65
|
+
- `export_render_failed` — niepoprawne dane albo wynik renderera.
|
|
66
|
+
|
|
67
|
+
Szczegóły błędu mogą wskazać sekcję, zestaw, wiersz i kolumnę, ale nie zawierają wartości danych.
|
|
68
|
+
Błędy programistyczne propagują wyjątek po usunięciu pliku częściowego. Udany artefakt należy
|
|
69
|
+
zawsze zamknąć przez `close!`/`cleanup` w `ensure`.
|
|
70
|
+
|
|
71
|
+
## Active Record
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
require "export_kit/active_record"
|
|
75
|
+
|
|
76
|
+
table = ExportKit.records(
|
|
77
|
+
policy_scope(Order).reorder(nil),
|
|
78
|
+
fields: %i[id number total_minor],
|
|
79
|
+
batch_size: 500
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Adapter wymaga relacji już ograniczonej przez aplikację. Może także pilnować kompletnej klasyfikacji
|
|
84
|
+
kolumn modelu: każda kolumna trafia wtedy na jawną allowlistę albo denylistę z powodem. To bezpiecznik
|
|
85
|
+
schematu, nie zamiennik Pundita ani innej autoryzacji.
|
|
86
|
+
|
|
87
|
+
Relacja do stronicowania musi być bez `order`, `offset`, `group` i `distinct` oraz mieć pojedynczy
|
|
88
|
+
klucz główny. Adapter stronicuje po kluczu i respektuje istniejący `limit`.
|
|
89
|
+
|
|
90
|
+
## Granica odpowiedzialności
|
|
91
|
+
|
|
92
|
+
Biblioteka odpowiada za mechanikę formatów, limity zapisu, neutralizację formuł, sprzątanie i
|
|
93
|
+
walidację struktury. Aplikacja odpowiada za:
|
|
94
|
+
|
|
95
|
+
- uwierzytelnienie, autoryzację i zakres danych;
|
|
96
|
+
- spójność odczytu;
|
|
97
|
+
- minimalizację danych i zgodność z wymaganiami prywatności;
|
|
98
|
+
- szyfrowanie, storage, pobieranie i retencję;
|
|
99
|
+
- zaufanie do własnych rendererów.
|
|
100
|
+
|
|
101
|
+
## Rozwój i wydania
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
bin/setup
|
|
105
|
+
bundle exec rake test
|
|
106
|
+
bundle exec rubocop
|
|
107
|
+
gem build export_kit.gemspec
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Wydania idą z GitHub Actions do RubyGems.org przez OIDC Trusted Publishing, bez długowiecznego
|
|
111
|
+
tokena w sekretach repo. Szczegóły: [docs/releasing.md](docs/releasing.md).
|
|
112
|
+
|
|
113
|
+
Kod jest dostępny na licencji [MIT](LICENSE.txt).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "export_kit"
|
|
4
|
+
require "active_record"
|
|
5
|
+
require "export_kit/model_schema"
|
|
6
|
+
require "export_kit/model_serializer"
|
|
7
|
+
require "export_kit/model_rows"
|
|
8
|
+
|
|
9
|
+
module ExportKit
|
|
10
|
+
def self.records(scope, fields:, labels: {}, batch_size: 500)
|
|
11
|
+
set = ModelSerializer::RecordSetBuilder.new(
|
|
12
|
+
"rows", model: scope.klass, scope: ->(_) { scope }, fields:, schema: nil
|
|
13
|
+
).build
|
|
14
|
+
Table.new(columns: set.output_keys, labels:, rows: ->(&emit) { set.each_row(batch_size:, &emit) })
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ExportKit
|
|
4
|
+
Artifact = Data.define(:file, :byte_size, :sha256, :manifest, :format, :content_type, :extension,
|
|
5
|
+
:identifier, :schema_version, :metadata, :document) do
|
|
6
|
+
def close!
|
|
7
|
+
file.close!
|
|
8
|
+
nil
|
|
9
|
+
end
|
|
10
|
+
alias_method :cleanup, :close!
|
|
11
|
+
|
|
12
|
+
def verify
|
|
13
|
+
valid ? Result.success(self) : Result.failure(ValidateArtifact::INVALID)
|
|
14
|
+
rescue Errno::ENOENT, IOError
|
|
15
|
+
Result.failure(ValidateArtifact::INVALID)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def valid = file.path && File.size(file.path) == byte_size && Digest::SHA256.file(file.path).hexdigest == sha256
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ExportKit
|
|
4
|
+
# Format-neutral input for export renderers. A source adapter describes the document and
|
|
5
|
+
# streams rows; renderers know nothing about Active Record or the domain that produced them.
|
|
6
|
+
class Document
|
|
7
|
+
KEY_FORMAT = /\A[a-z][a-z0-9_]{0,63}\z/
|
|
8
|
+
IDENTIFIER_FORMAT = /\A[a-z][a-z0-9_]{2,63}\z/
|
|
9
|
+
|
|
10
|
+
attr_reader :identifier, :metadata, :schema_version, :sections, :title
|
|
11
|
+
|
|
12
|
+
def self.string(value) = value.to_s.dup.freeze
|
|
13
|
+
|
|
14
|
+
def self.normalize_metadata(value, depth = 0)
|
|
15
|
+
raise ArgumentError, "metadata nesting exceeds limit" if depth > 100
|
|
16
|
+
|
|
17
|
+
case value
|
|
18
|
+
when Hash then normalize_hash(value, depth)
|
|
19
|
+
when Array then value.map { normalize_metadata(it, depth + 1) }.freeze
|
|
20
|
+
when String then value.dup.freeze
|
|
21
|
+
when Integer, Float, TrueClass, FalseClass, NilClass then value
|
|
22
|
+
else raise ArgumentError, "unsupported metadata value"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.normalize_hash(value, depth)
|
|
27
|
+
keys = value.keys.map(&:to_s)
|
|
28
|
+
raise ArgumentError, "duplicate metadata keys" unless keys.uniq == keys
|
|
29
|
+
|
|
30
|
+
value.to_h { |key, item| [string(key), normalize_metadata(item, depth + 1)] }.freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.validate_metadata!(metadata, reserved)
|
|
34
|
+
raise ArgumentError, "metadata must be a hash" unless metadata.is_a?(Hash)
|
|
35
|
+
raise ArgumentError, "reserved metadata key" if metadata.keys.intersect?(reserved)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# One logical group in a document. Metadata is copied into every format manifest.
|
|
39
|
+
class Section
|
|
40
|
+
attr_reader :description, :key, :label, :metadata, :record_sets
|
|
41
|
+
|
|
42
|
+
def initialize(key:, label:, description:, metadata:, record_sets:)
|
|
43
|
+
@key = Document.string(key)
|
|
44
|
+
@label = Document.string(label)
|
|
45
|
+
@description = Document.string(description)
|
|
46
|
+
@metadata = Document.normalize_metadata(metadata)
|
|
47
|
+
@record_sets = Array(record_sets).dup.freeze
|
|
48
|
+
validate!
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def validate!
|
|
54
|
+
Document.validate_metadata!(metadata, %w(category label description record_sets count))
|
|
55
|
+
raise ArgumentError, "invalid export section key" unless key.match?(KEY_FORMAT)
|
|
56
|
+
raise ArgumentError, "export section label is required" if label.strip.empty?
|
|
57
|
+
|
|
58
|
+
validate_record_sets!
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_record_sets!
|
|
62
|
+
raise ArgumentError, "invalid record sets" unless record_sets.all?(RecordSet)
|
|
63
|
+
return if record_sets.map(&:name).uniq.size == record_sets.size
|
|
64
|
+
|
|
65
|
+
raise ArgumentError, "export record-set names must be unique"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A named table with an explicit column order and a callback that yields one Hash per row.
|
|
70
|
+
class RecordSet
|
|
71
|
+
attr_reader :column_labels, :columns, :name
|
|
72
|
+
|
|
73
|
+
def initialize(name:, columns:, row_source:, column_labels: {})
|
|
74
|
+
@name = Document.string(name)
|
|
75
|
+
@columns = Array(columns).map { Document.string(it) }.freeze
|
|
76
|
+
@column_labels = @columns.to_h { [it, it] }.merge(
|
|
77
|
+
column_labels.to_h { |key, value| [Document.string(key), Document.string(value)] }
|
|
78
|
+
).freeze
|
|
79
|
+
@row_source = row_source
|
|
80
|
+
validate!
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def headers
|
|
84
|
+
columns.map { column_labels.fetch(it) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def each_row(&)
|
|
88
|
+
row_source.call(&)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
attr_reader :row_source
|
|
94
|
+
|
|
95
|
+
def validate!
|
|
96
|
+
validate_name!
|
|
97
|
+
validate_columns!
|
|
98
|
+
validate_labels!
|
|
99
|
+
validate_row_source!
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_name!
|
|
103
|
+
raise ArgumentError, "invalid export record-set name" unless name.match?(KEY_FORMAT)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate_columns!
|
|
107
|
+
raise ArgumentError, "export columns must be unique" unless columns.uniq.size == columns.size
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def validate_labels!
|
|
111
|
+
raise ArgumentError, "export column labels must match columns" unless column_labels.keys == columns
|
|
112
|
+
raise ArgumentError, "export column labels must be unique" unless headers.uniq.size == headers.size
|
|
113
|
+
raise ArgumentError, "export column labels must be present" if headers.any? { it.to_s.strip.empty? }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def validate_row_source!
|
|
117
|
+
raise ArgumentError, "export row source must be callable" unless row_source.respond_to?(:call)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def initialize(identifier:, schema_version:, title:, metadata:, sections:)
|
|
122
|
+
@identifier = Document.string(identifier)
|
|
123
|
+
@schema_version = Integer(schema_version)
|
|
124
|
+
@title = Document.string(title)
|
|
125
|
+
@metadata = Document.normalize_metadata(metadata)
|
|
126
|
+
@sections = Array(sections).dup.freeze
|
|
127
|
+
validate!
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def envelope(manifest)
|
|
131
|
+
{ "format" => identifier, "schema_version" => schema_version, **metadata, "manifest" => manifest }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def validate!
|
|
137
|
+
Document.validate_metadata!(metadata, %w(format schema_version manifest data))
|
|
138
|
+
validate_identifier!
|
|
139
|
+
validate_schema_version!
|
|
140
|
+
validate_title!
|
|
141
|
+
validate_sections!
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate_identifier!
|
|
145
|
+
raise ArgumentError, "invalid export document identifier" unless identifier.match?(IDENTIFIER_FORMAT)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def validate_schema_version!
|
|
149
|
+
raise ArgumentError, "invalid export schema version" unless schema_version.positive?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def validate_title!
|
|
153
|
+
raise ArgumentError, "export document title is required" if title.strip.empty?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def validate_sections!
|
|
157
|
+
unless sections.all?(Section)
|
|
158
|
+
raise ArgumentError, "export document sections must be ExportKit::Document::Section instances"
|
|
159
|
+
end
|
|
160
|
+
return if sections.map(&:key).uniq.size == sections.size
|
|
161
|
+
|
|
162
|
+
raise ArgumentError, "export document needs unique sections"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ExportKit
|
|
4
|
+
module FormatRegistry
|
|
5
|
+
Definition = Data.define(:key, :extension, :content_type)
|
|
6
|
+
DEFINITIONS = [
|
|
7
|
+
Definition.new(key: "json", extension: "json", content_type: "application/json"),
|
|
8
|
+
Definition.new(key: "csv", extension: "csv", content_type: "text/csv"),
|
|
9
|
+
Definition.new(key: "csv_zip", extension: "zip", content_type: "application/zip"),
|
|
10
|
+
Definition.new(key: "pdf", extension: "pdf", content_type: "application/pdf")
|
|
11
|
+
].to_h { [it.key, it] }.freeze
|
|
12
|
+
|
|
13
|
+
def self.find(key) = DEFINITIONS[key.to_s]
|
|
14
|
+
end
|
|
15
|
+
end
|