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 +4 -4
- data/lib/forest_admin_agent/http/router.rb +1 -0
- 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/routes/resources/update_field.rb +140 -0
- data/lib/forest_admin_agent/utils/csv_generator_stream.rb +111 -0
- data/lib/forest_admin_agent/utils/id.rb +1 -1
- data/lib/forest_admin_agent/utils/schema/schema_emitter.rb +1 -1
- data/lib/forest_admin_agent/version.rb +1 -1
- metadata +3 -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
|
|
@@ -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
|
-
|
|
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,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(
|
|
30
|
+
ForestAdminDatasourceToolkit::Validations::FieldValidator.validate_value(pk_name, field, casted_value)
|
|
31
31
|
|
|
32
32
|
[pk_name, casted_value]
|
|
33
33
|
end.to_h
|
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
|
|
@@ -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
|