forest_admin_agent 1.10.0 → 1.11.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 +4 -4
- data/lib/forest_admin_agent/routes/resources/csv.rb +27 -6
- data/lib/forest_admin_agent/routes/resources/related/csv_related.rb +37 -14
- data/lib/forest_admin_agent/utils/csv_generator_stream.rb +111 -0
- data/lib/forest_admin_agent/utils/schema/schema_emitter.rb +1 -1
- data/lib/forest_admin_agent/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d4ece5f90c8cf5ecc42aeac33df998c280e581af076edd9fde12f34860f10737
|
4
|
+
data.tar.gz: 7dff9b95291a1add4102376a9e609a1e97c1a010c1f5a2292921de2e09cbf31d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 19b5b40cfc2492918a284f6409a79be709aea4d245e3673f9b811543327540be68cc850c2483cd1773672df7df62960fe450d702b9cb5a6caebdee01ecc0f687
|
7
|
+
data.tar.gz: b4fbf11c6492f9d3e2d75ebf6ec0ce08a850ec06fdcf7596c9abbe2cc6929d87fa5b70721e452acbd3015b9ebdd90a107e63c6573aed2d479c5980dfd03d250f
|
@@ -32,20 +32,41 @@ module ForestAdminAgent
|
|
32
32
|
)
|
33
33
|
]
|
34
34
|
),
|
35
|
-
page: QueryStringParser.parse_export_pagination(Facades::Container.config_from_cache[:limit_export_size]),
|
36
35
|
search: QueryStringParser.parse_search(@collection, args),
|
37
|
-
search_extended: QueryStringParser.parse_search_extended(args)
|
36
|
+
search_extended: QueryStringParser.parse_search_extended(args),
|
37
|
+
sort: QueryStringParser.parse_sort(@collection, args),
|
38
|
+
segment: QueryStringParser.parse_segment(@collection, args)
|
38
39
|
)
|
39
40
|
projection = QueryStringParser.parse_projection(@collection, args)
|
40
|
-
|
41
|
-
filename = args[:params][:filename] || "#{args[:params]["collection_name"]}.csv"
|
41
|
+
filename = args[:params][:filename] || args[:params]['collection_name']
|
42
42
|
filename += '.csv' unless /\.csv$/i.match?(filename)
|
43
|
+
header = args[:params][:header]
|
44
|
+
|
45
|
+
# Generate timestamp for filename
|
46
|
+
now = Time.now.strftime('%Y%m%d_%H%M%S')
|
47
|
+
filename_with_timestamp = filename.gsub('.csv', "_export_#{now}.csv")
|
48
|
+
|
49
|
+
# Return streaming enumerator instead of full CSV string
|
50
|
+
list_records = ->(batch_filter) { @collection.list(@caller, batch_filter, projection) }
|
43
51
|
|
44
52
|
{
|
45
53
|
content: {
|
46
|
-
|
54
|
+
type: 'Stream',
|
55
|
+
enumerator: Utils::CsvGeneratorStream.stream(
|
56
|
+
header,
|
57
|
+
filter,
|
58
|
+
projection,
|
59
|
+
list_records,
|
60
|
+
Facades::Container.config_from_cache[:limit_export_size]
|
61
|
+
),
|
62
|
+
headers: {
|
63
|
+
'Content-Type' => 'text/csv; charset=utf-8',
|
64
|
+
'Content-Disposition' => "attachment; filename=\"#{filename_with_timestamp}\"",
|
65
|
+
'Cache-Control' => 'no-cache',
|
66
|
+
'X-Accel-Buffering' => 'no'
|
67
|
+
}
|
47
68
|
},
|
48
|
-
|
69
|
+
status: 200
|
49
70
|
}
|
50
71
|
end
|
51
72
|
end
|
@@ -30,28 +30,51 @@ module ForestAdminAgent
|
|
30
30
|
@permissions.get_scope(@collection),
|
31
31
|
ForestAdminAgent::Utils::QueryStringParser.parse_condition_tree(@child_collection, args)
|
32
32
|
]
|
33
|
-
)
|
34
|
-
page: QueryStringParser.parse_export_pagination(Facades::Container.config_from_cache[:limit_export_size])
|
33
|
+
)
|
35
34
|
)
|
36
35
|
projection = ForestAdminAgent::Utils::QueryStringParser.parse_projection_with_pks(@child_collection, args)
|
36
|
+
|
37
|
+
# Get the parent record ID
|
37
38
|
id = Utils::Id.unpack_id(@collection, args[:params]['id'], with_key: true)
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
)
|
39
|
+
relation_name = args[:params]['relation_name']
|
40
|
+
|
41
|
+
# Generate timestamp for filename
|
42
|
+
now = Time.now.strftime('%Y%m%d_%H%M%S')
|
43
|
+
collection_name = args.dig(:params, 'collection_name')
|
44
|
+
header = args.dig(:params, 'header')
|
45
|
+
filename_with_timestamp = "#{collection_name}_#{relation_name}_export_#{now}.csv"
|
46
46
|
|
47
|
-
|
48
|
-
|
47
|
+
# Create a callable to fetch related records
|
48
|
+
list_records = lambda do |batch_filter|
|
49
|
+
ForestAdminDatasourceToolkit::Utils::Collection.list_relation(
|
50
|
+
@collection,
|
51
|
+
id,
|
52
|
+
relation_name,
|
53
|
+
@caller,
|
54
|
+
batch_filter,
|
55
|
+
projection
|
56
|
+
)
|
57
|
+
end
|
49
58
|
|
59
|
+
# For related exports, use list_relation to fetch records
|
50
60
|
{
|
51
61
|
content: {
|
52
|
-
|
62
|
+
type: 'Stream',
|
63
|
+
enumerator: ForestAdminAgent::Utils::CsvGeneratorStream.stream(
|
64
|
+
header,
|
65
|
+
filter,
|
66
|
+
projection,
|
67
|
+
list_records,
|
68
|
+
Facades::Container.config_from_cache[:limit_export_size]
|
69
|
+
),
|
70
|
+
headers: {
|
71
|
+
'Content-Type' => 'text/csv; charset=utf-8',
|
72
|
+
'Content-Disposition' => "attachment; filename=\"#{filename_with_timestamp}\"",
|
73
|
+
'Cache-Control' => 'no-cache',
|
74
|
+
'X-Accel-Buffering' => 'no'
|
75
|
+
}
|
53
76
|
},
|
54
|
-
|
77
|
+
status: 200
|
55
78
|
}
|
56
79
|
end
|
57
80
|
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'csv'
|
4
|
+
|
5
|
+
module ForestAdminAgent
|
6
|
+
module Utils
|
7
|
+
class CsvGeneratorStream
|
8
|
+
CHUNK_SIZE = 1000
|
9
|
+
|
10
|
+
# @param header [Array<String>] CSV header fields
|
11
|
+
# @param filter [ForestAdminDatasourceToolkit::Components::Query::Filter] Query filter
|
12
|
+
# @param projection [ForestAdminDatasourceToolkit::Components::Query::Projection] Fields to include
|
13
|
+
# @param list_records [Proc] A callable that fetches records: ->(batch_filter) { ... }
|
14
|
+
# @param limit_export_size [Integer, nil] Maximum number of records to export
|
15
|
+
# @return [Enumerator] Lazy enumerator that yields CSV rows
|
16
|
+
def self.stream(header, filter, projection, list_records, limit_export_size = nil)
|
17
|
+
Enumerator.new do |yielder|
|
18
|
+
# Yield header row first (client receives immediately)
|
19
|
+
header_array = parse_header(header, projection)
|
20
|
+
yielder << "#{header_array.join(",")}\n"
|
21
|
+
|
22
|
+
offset = 0
|
23
|
+
|
24
|
+
loop do
|
25
|
+
# Fetch batch of records
|
26
|
+
batch_filter = filter.override(
|
27
|
+
page: ForestAdminDatasourceToolkit::Components::Query::Page.new(offset: offset, limit: CHUNK_SIZE)
|
28
|
+
)
|
29
|
+
records = list_records.call(batch_filter)
|
30
|
+
|
31
|
+
# Break if no more records
|
32
|
+
break if records.empty?
|
33
|
+
|
34
|
+
# Convert each record to CSV row and yield immediately
|
35
|
+
records.each do |record|
|
36
|
+
yielder << generate_row(record, projection)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Update offset
|
40
|
+
offset += CHUNK_SIZE
|
41
|
+
|
42
|
+
# Check if we've reached the export limit
|
43
|
+
break if limit_export_size && offset >= limit_export_size
|
44
|
+
|
45
|
+
# Check if this was a partial batch (last batch)
|
46
|
+
break if records.length < CHUNK_SIZE
|
47
|
+
|
48
|
+
# Periodic garbage collection to prevent memory creep
|
49
|
+
GC.start(full_mark: false) if (offset % 10_000).zero?
|
50
|
+
end
|
51
|
+
rescue IOError, Errno::EPIPE => e
|
52
|
+
# Client disconnected - clean up gracefully
|
53
|
+
Facades::Container.logger&.log(
|
54
|
+
'Info',
|
55
|
+
"CSV export interrupted at offset #{offset}: #{e.message}"
|
56
|
+
)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Parse header parameter into an array
|
61
|
+
# @param header [String, Array, nil] Header as JSON string, array, or nil
|
62
|
+
# @param projection [Projection] Field projection (fallback if header is invalid)
|
63
|
+
# @return [Array<String>] Header fields
|
64
|
+
def self.parse_header(header, projection)
|
65
|
+
case header
|
66
|
+
when Array
|
67
|
+
header
|
68
|
+
when String
|
69
|
+
return projection.to_a if header.empty?
|
70
|
+
|
71
|
+
JSON.parse(header)
|
72
|
+
else
|
73
|
+
projection.to_a
|
74
|
+
end
|
75
|
+
rescue JSON::ParserError
|
76
|
+
# Fallback to projection if JSON parsing fails
|
77
|
+
projection.to_a
|
78
|
+
end
|
79
|
+
|
80
|
+
# Generate CSV row from record data
|
81
|
+
# @param record [Hash] Record data
|
82
|
+
# @param projection [Projection] Field projection
|
83
|
+
# @return [String] CSV data line
|
84
|
+
def self.generate_row(record, projection)
|
85
|
+
values = projection.map do |field|
|
86
|
+
value = ForestAdminDatasourceToolkit::Utils::Record.field_value(record, field)
|
87
|
+
format_value(value)
|
88
|
+
end
|
89
|
+
CSV.generate_line(values)
|
90
|
+
end
|
91
|
+
|
92
|
+
# Format individual value for CSV output
|
93
|
+
# @param value [Object] Value to format
|
94
|
+
# @return [String] Formatted value
|
95
|
+
def self.format_value(value)
|
96
|
+
case value
|
97
|
+
when nil
|
98
|
+
''
|
99
|
+
when Array, Hash
|
100
|
+
value.to_json
|
101
|
+
when Date, DateTime, Time
|
102
|
+
value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
|
103
|
+
else
|
104
|
+
value.to_s
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
private_class_method :parse_header, :generate_row, :format_value
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: forest_admin_agent
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.11.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Matthieu
|
@@ -281,6 +281,7 @@ files:
|
|
281
281
|
- lib/forest_admin_agent/utils/context_variables.rb
|
282
282
|
- lib/forest_admin_agent/utils/context_variables_injector.rb
|
283
283
|
- lib/forest_admin_agent/utils/csv_generator.rb
|
284
|
+
- lib/forest_admin_agent/utils/csv_generator_stream.rb
|
284
285
|
- lib/forest_admin_agent/utils/error_messages.rb
|
285
286
|
- lib/forest_admin_agent/utils/id.rb
|
286
287
|
- lib/forest_admin_agent/utils/query_string_parser.rb
|