forest_admin_agent 1.9.1 → 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: d85148dc0247c3d0d74c458344980576ec7aa14772a8015d237b2b63cfdd88c3
4
- data.tar.gz: ba8f07da138797ad91f931ab56db345dd37d3f8ca13d95655b28af2180594b8b
3
+ metadata.gz: d4ece5f90c8cf5ecc42aeac33df998c280e581af076edd9fde12f34860f10737
4
+ data.tar.gz: 7dff9b95291a1add4102376a9e609a1e97c1a010c1f5a2292921de2e09cbf31d
5
5
  SHA512:
6
- metadata.gz: e4c49cc7ef501eeb34283106e9f21e12b992570afb17974ba30581d7076749d2d647e5bc539fc22145d75deaf693f547a7c1e2441285967244479cd40c44ffad
7
- data.tar.gz: 4468d234313447da09cbfb88907db657bc0ba51f238c5fa18c43aa45b274d59eb0593507838c1cd78c387a1ae6ec158d83e3f524823889ad13fc22c6c85af568
6
+ metadata.gz: 19b5b40cfc2492918a284f6409a79be709aea4d245e3673f9b811543327540be68cc850c2483cd1773672df7df62960fe450d702b9cb5a6caebdee01ecc0f687
7
+ data.tar.gz: b4fbf11c6492f9d3e2d75ebf6ec0ce08a850ec06fdcf7596c9abbe2cc6929d87fa5b70721e452acbd3015b9ebdd90a107e63c6573aed2d479c5980dfd03d250f
@@ -20,6 +20,7 @@ module ForestAdminAgent
20
20
  Resources::Show.new.routes,
21
21
  Resources::Store.new.routes,
22
22
  Resources::Update.new.routes,
23
+ Resources::UpdateField.new.routes,
23
24
  Resources::Related::CsvRelated.new.routes,
24
25
  Resources::Related::ListRelated.new.routes,
25
26
  Resources::Related::CountRelated.new.routes,
@@ -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,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jsonapi-serializers'
4
+
5
+ module ForestAdminAgent
6
+ module Routes
7
+ module Resources
8
+ class UpdateField < AbstractAuthenticatedRoute
9
+ include ForestAdminAgent::Builder
10
+ include ForestAdminDatasourceToolkit::Components::Query
11
+ include ForestAdminDatasourceToolkit::Validations
12
+ include ForestAdminDatasourceToolkit::Schema
13
+
14
+ def setup_routes
15
+ add_route(
16
+ 'forest_update_field',
17
+ 'put',
18
+ '/:collection_name/:id/relationships/:field_name/:index',
19
+ ->(args) { handle_request(args) }
20
+ )
21
+
22
+ self
23
+ end
24
+
25
+ def handle_request(args = {})
26
+ build(args)
27
+
28
+ record_id = Utils::Id.unpack_id(@collection, args[:params]['id'], with_key: true)
29
+ field_name = args[:params]['field_name']
30
+ array_index = parse_index(args[:params]['index'])
31
+
32
+ @permissions.can?(:edit, @collection)
33
+
34
+ field_schema = @collection.schema[:fields][field_name]
35
+ validate_array_field!(field_schema, field_name)
36
+
37
+ record = fetch_record(record_id)
38
+
39
+ array = record[field_name]
40
+ validate_array_value!(array, field_name, array_index)
41
+
42
+ new_value = parse_value_from_body(args[:params], field_schema)
43
+
44
+ updated_array = array.dup
45
+ updated_array[array_index] = new_value
46
+
47
+ scope = @permissions.get_scope(@collection)
48
+ condition_tree = ConditionTree::ConditionTreeFactory.match_records(@collection, [record_id])
49
+ filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new(
50
+ condition_tree: ConditionTree::ConditionTreeFactory.intersect([condition_tree, scope])
51
+ )
52
+ @collection.update(@caller, filter, { field_name => updated_array })
53
+
54
+ records = @collection.list(@caller, filter, ProjectionFactory.all(@collection))
55
+
56
+ {
57
+ name: args[:params]['collection_name'],
58
+ content: JSONAPI::Serializer.serialize(
59
+ records[0],
60
+ is_collection: false,
61
+ class_name: @collection.name,
62
+ serializer: Serializer::ForestSerializer
63
+ )
64
+ }
65
+ end
66
+
67
+ private
68
+
69
+ def parse_index(index_param)
70
+ index = Integer(index_param)
71
+ raise Http::Exceptions::ValidationError, 'Index must be non-negative' if index.negative?
72
+
73
+ index
74
+ rescue ArgumentError
75
+ raise Http::Exceptions::ValidationError, "Invalid index: #{index_param}"
76
+ end
77
+
78
+ def validate_array_field!(field_schema, field_name)
79
+ FieldValidator.validate(@collection, field_name)
80
+ return if field_schema.column_type.to_s.start_with?('[')
81
+
82
+ raise Http::Exceptions::ValidationError,
83
+ "Field '#{field_name}' is not an array (type: #{field_schema.column_type})"
84
+ rescue ForestAdminDatasourceToolkit::Exceptions::ValidationError => e
85
+ raise Http::Exceptions::NotFoundError, e.message if e.message.include?('not found')
86
+
87
+ raise Http::Exceptions::ValidationError, e.message
88
+ end
89
+
90
+ def fetch_record(record_id)
91
+ scope = @permissions.get_scope(@collection)
92
+ condition_tree = ConditionTree::ConditionTreeFactory.match_records(@collection, [record_id])
93
+ filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new(
94
+ condition_tree: ConditionTree::ConditionTreeFactory.intersect([condition_tree, scope])
95
+ )
96
+ records = @collection.list(@caller, filter, ProjectionFactory.all(@collection))
97
+
98
+ raise Http::Exceptions::NotFoundError, 'Record not found' unless records&.any?
99
+
100
+ records[0]
101
+ end
102
+
103
+ def validate_array_value!(array, field_name, array_index)
104
+ unless array.is_a?(Array)
105
+ raise Http::Exceptions::UnprocessableError,
106
+ "Field '#{field_name}' value is not an array (got: #{array.class})"
107
+ end
108
+
109
+ return unless array_index >= array.length
110
+
111
+ raise Http::Exceptions::ValidationError,
112
+ "Index #{array_index} out of bounds for array of length #{array.length}"
113
+ end
114
+
115
+ def parse_value_from_body(params, field_schema)
116
+ body = params[:data] || {}
117
+ value = body.dig(:attributes, 'value') || body.dig(:attributes, :value)
118
+
119
+ coerce_value(value, field_schema.column_type)
120
+ end
121
+
122
+ def coerce_value(value, column_type)
123
+ return value unless column_type.to_s.start_with?('[') && column_type.to_s.end_with?(']')
124
+
125
+ element_type = column_type.to_s[1..-2]
126
+
127
+ if element_type == 'Number' && value.is_a?(String)
128
+ begin
129
+ return Float(value)
130
+ rescue ArgumentError
131
+ raise Http::Exceptions::ValidationError, "Cannot coerce '#{value}' to Number - wrong type"
132
+ end
133
+ end
134
+
135
+ value
136
+ end
137
+ end
138
+ end
139
+ end
140
+ 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
@@ -27,7 +27,7 @@ module ForestAdminAgent
27
27
  field = collection.schema[:fields][pk_name]
28
28
  value = primary_key_values[index]
29
29
  casted_value = field.column_type == 'Number' ? value.to_i : value
30
- ForestAdminDatasourceToolkit::Validations::FieldValidator.validate_value(value, field, casted_value)
30
+ ForestAdminDatasourceToolkit::Validations::FieldValidator.validate_value(pk_name, field, casted_value)
31
31
 
32
32
  [pk_name, casted_value]
33
33
  end.to_h
@@ -6,7 +6,7 @@ module ForestAdminAgent
6
6
  module Schema
7
7
  class SchemaEmitter
8
8
  LIANA_NAME = "agent-ruby"
9
- LIANA_VERSION = "1.9.1"
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.9.1"
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.9.1
4
+ version: 1.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthieu
@@ -264,6 +264,7 @@ files:
264
264
  - lib/forest_admin_agent/routes/resources/show.rb
265
265
  - lib/forest_admin_agent/routes/resources/store.rb
266
266
  - lib/forest_admin_agent/routes/resources/update.rb
267
+ - lib/forest_admin_agent/routes/resources/update_field.rb
267
268
  - lib/forest_admin_agent/routes/security/authentication.rb
268
269
  - lib/forest_admin_agent/routes/security/scope_invalidation.rb
269
270
  - lib/forest_admin_agent/routes/system/health_check.rb
@@ -280,6 +281,7 @@ files:
280
281
  - lib/forest_admin_agent/utils/context_variables.rb
281
282
  - lib/forest_admin_agent/utils/context_variables_injector.rb
282
283
  - lib/forest_admin_agent/utils/csv_generator.rb
284
+ - lib/forest_admin_agent/utils/csv_generator_stream.rb
283
285
  - lib/forest_admin_agent/utils/error_messages.rb
284
286
  - lib/forest_admin_agent/utils/id.rb
285
287
  - lib/forest_admin_agent/utils/query_string_parser.rb