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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e8e0238988884b3db56ac66a0478dc662ea5fed4377d50b2de685f414c455cb
4
- data.tar.gz: 7986a7262febae19442a08d0916362edd97ff655d2fad7f688d43babc14e8af1
3
+ metadata.gz: d4ece5f90c8cf5ecc42aeac33df998c280e581af076edd9fde12f34860f10737
4
+ data.tar.gz: 7dff9b95291a1add4102376a9e609a1e97c1a010c1f5a2292921de2e09cbf31d
5
5
  SHA512:
6
- metadata.gz: d1f931091e7e9e836208337f190bc0442e7543e55afd232ffc2c98331823f1f235fa71eeae574b13a907009756783fd3d17592fe7540f091a827cee9a2e44ad2
7
- data.tar.gz: 69f51337fc2eb92f6eba5f0c0d1decd260f61bb2703975336cec22c857452bb87ecbc5bc1a914444e5ec38a9052430df35f579124c3ac7a55cef61fd21a0152a
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
- records = @collection.list(@caller, filter, projection)
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
- export: CsvGenerator.generate(records, projection)
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
- filename: filename
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
- records = Collection.list_relation(
39
- @collection,
40
- id,
41
- args[:params]['relation_name'],
42
- @caller,
43
- filter,
44
- projection
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
- filename = args[:params][:filename] || "#{args[:params]["relation_name"]}.csv"
48
- filename += '.csv' unless /\.csv$/i.match?(filename)
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
- export: CsvGenerator.generate(records, projection)
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
- filename: filename
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
@@ -6,7 +6,7 @@ module ForestAdminAgent
6
6
  module Schema
7
7
  class SchemaEmitter
8
8
  LIANA_NAME = "agent-ruby"
9
- LIANA_VERSION = "1.10.0"
9
+ LIANA_VERSION = "1.11.0"
10
10
 
11
11
  def self.generate(datasource)
12
12
  datasource.collections
@@ -1,3 +1,3 @@
1
1
  module ForestAdminAgent
2
- VERSION = "1.10.0"
2
+ VERSION = "1.11.0"
3
3
  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.10.0
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