Skip to content

Commit

Permalink
feat: add binary decorator support (#42)
Browse files Browse the repository at this point in the history
  • Loading branch information
nicolasalexandre9 authored May 2, 2024
1 parent 3b4e437 commit 0e03047
Show file tree
Hide file tree
Showing 12 changed files with 608 additions and 4 deletions.
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ Metrics/ClassLength:
- 'packages/forest_admin_agent/lib/forest_admin_agent/routes/charts/charts.rb'
- 'packages/forest_admin_agent/lib/forest_admin_agent/routes/action/action.rb'
- 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/collection_customizer.rb'
- 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/binary/binary_collection_decorator.rb'
- 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb'
- 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/rename_field/rename_field_collection_decorator.rb'
- 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/collection.rb'
Expand Down
2 changes: 1 addition & 1 deletion packages/forest_admin_datasource_active_record/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ group :development, :test do
gem 'simplecov', '~> 0.22', require: false
gem 'simplecov-html', '~> 0.12.3'
gem 'simplecov_json_formatter', '~> 0.1.4'
gem 'sqlite3'
gem 'sqlite3', '< 2.0'
end
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,6 @@ admin work on any Ruby application."
spec.require_paths = ["lib"]

spec.add_dependency "activesupport", ">= 6.1"
spec.add_dependency 'marcel', '~> 1.0', '>= 1.0.4'
spec.add_dependency "zeitwerk", "~> 2.3"
end
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,21 @@ def add_segment(name, definition)
push_customization { @stack.segment.get_collection(@name).add_segment(name, definition) }
end

# Choose how binary data should be transported to the GUI.
# By default, all fields are transported as 'datauri', with the exception of primary and foreign
# keys.
#
# Using 'datauri' allows to use the FilePicker widget, while 'hex' is more suitable for
# short binary data (for instance binary uuids).
#
# @param name the name of the field
# @param binary_mode either 'datauri' or 'hex'
# @example
# .replace_field_binary_mode('avatar', 'datauri');
def replace_field_binary_mode(name, binary_mode)
push_customization { @stack.binary.get_collection(@name).set_binary_mode(name, binary_mode) }
end

private

def push_customization(&customization)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
require 'base64'
require 'marcel'

module ForestAdminDatasourceCustomizer
module Decorators
module Binary
class BinaryCollectionDecorator < ForestAdminDatasourceToolkit::Decorators::CollectionDecorator
include ForestAdminDatasourceToolkit
include ForestAdminDatasourceToolkit::Decorators
include ForestAdminDatasourceToolkit::Components::Query::ConditionTree

OPERATORS_WITH_REPLACEMENT = [Operators::AFTER, Operators::BEFORE, Operators::CONTAINS,
Operators::ENDS_WITH, Operators::EQUAL, Operators::GREATER_THAN,
Operators::I_CONTAINS, Operators::NOT_IN, Operators::I_ENDS_WITH,
Operators::I_STARTS_WITH, Operators::LESS_THAN, Operators::NOT_CONTAINS,
Operators::NOT_EQUAL, Operators::STARTS_WITH, Operators::IN].freeze

def initialize(child_collection, datasource)
super
@use_hex_conversion = {}
end

def set_binary_mode(name, type)
field = @child_collection.schema[:fields][name]

raise Exceptions::ForestException, 'Invalid binary mode' unless %w[datauri hex].include?(type)

unless field&.type == 'Column' && field&.column_type == 'Binary'
raise Exceptions::ForestException, 'Expected a binary field'
end

@use_hex_conversion[name] = (type == 'hex')
mark_schema_as_dirty
end

def refine_schema(sub_schema)
fields = {}

sub_schema[:fields].each do |name, schema|
if schema.type == 'Column'
new_schema = schema.dup
new_schema.column_type = replace_column_type(schema.column_type)
new_schema.validations = replace_validation(name, schema)
fields[name] = new_schema
else
fields[name] = schema
end
end

sub_schema[:fields] = fields
sub_schema
end

def refine_filter(_caller, filter = nil)
filter&.override(
condition_tree: filter&.condition_tree&.replace_leafs do |leaf|
convert_condition_tree_leaf(leaf)
end
)
end

def create(caller, data)
data_with_binary = convert_record(true, data)
record = super(caller, data_with_binary)

convert_record(false, record)
end

def list(caller, filter, projection)
records = super(caller, filter, projection)
records.map! { |record| convert_record(false, record) }

records
end

def update(caller, filter, patch)
super(caller, filter, convert_record(true, patch))
end

def aggregate(caller, filter, aggregation, limit = nil)
rows = super
rows.map! do |row|
{
'value' => row['value'],
'group' => row['group'].to_h { |path, value| [path, convert_value(false, path, value)] }
}
end
end

def convert_condition_tree_leaf(leaf)
prefix, suffix = leaf.field.split(':')
schema = @child_collection.schema[:fields][prefix]

if schema.type != 'Column'
condition_tree = @datasource.get_collection(schema.foreign_collection).convert_condition_tree_leaf(
leaf.override(field: suffix)
)

return condition_tree.nest(prefix)
end

if OPERATORS_WITH_REPLACEMENT.include?(leaf.operator)
column_type = if [Operators::IN, Operators::NOT_IN].include?(leaf.operator)
[schema.column_type]
else
schema.column_type
end

return leaf.override(
value: convert_value_helper(true, column_type, should_use_hex(prefix), leaf.value)
)
end

leaf
end

def should_use_hex(name)
return @use_hex_conversion[name] if @use_hex_conversion.key?(name)

Utils::Schema.primary_key?(@child_collection, name) || Utils::Schema.foreign_key?(@child_collection, name)
end

def convert_record(to_backend, record)
if record
record = record.to_h do |path, value|
[path, convert_value(to_backend, path, value)]
end
end

record
end

def convert_value(to_backend, path, value)
prefix, suffix = path.split(':')
field = @child_collection.schema[:fields][prefix]

if field.type != 'Column'
foreign_collection = @datasource.get_collection(field.foreign_collection)

return suffix ? foreign_collection.convert_value(to_backend, suffix,
value) : foreign_collection.convert_record(to_backend,
value)
end

binary_mode = should_use_hex(path)

convert_value_helper(to_backend, field.column_type, binary_mode, value)
end

def convert_value_helper(to_backend, column_type, use_hex, value)
if value
return convert_scalar(to_backend, use_hex, value) if column_type == 'Binary'

if column_type.is_a? Array
return value.map { |v| convert_value_helper(to_backend, column_type[0], use_hex, v) }
end

unless column_type.is_a? String
return column_type.to_h { |key, type| [key, convert_value_helper(to_backend, type, use_hex, value[key])] }
end
end

value
end

def convert_scalar(to_backend, use_hex, value)
if to_backend
return use_hex ? BinaryHelper.hex_to_bin(value) : Base64.strict_decode64(value.partition(',')[2])
end

return BinaryHelper.bin_to_hex(value) if use_hex

data = Base64.strict_encode64(value)
mime = Marcel::MimeType.for StringIO.new(value)

"data:#{mime};base64,#{data}"
end

def replace_column_type(column_type)
if column_type.is_a? String
return column_type == 'Binary' ? 'String' : column_type
end

return [replace_column_type(column_type[0])] if column_type.is_a? Array

column_type.transform_values { |type| replace_column_type(type) }
end

def replace_validation(name, column_schema)
if column_schema.column_type == 'Binary'
validations = []
min_length = (column_schema.validations.find { |v| v[:operator] == Operators::LONGER_THAN } || {})[:value]
max_length = (column_schema.validations.find { |v| v[:operator] == Operators::SHORTER_THAN } || {})[:value]

if should_use_hex(name)
validations << { operator: Operators::MATCH, value: '/^[0-9a-f]+$/' }
validations << { operator: Operators::LONGER_THAN, value: (min_length * 2) + 1 } if min_length
validations << { operator: Operators::SHORTER_THAN, value: (max_length * 2) - 1 } if max_length
else
validations << { operator: Operators::MATCH, value: '/^data:.*;base64,.*/' }
end

if column_schema.validations.find { |v| v[:operator] == Operators::PRESENT }
validations << { operator: Operators::PRESENT }
end

return validations
end

column_schema.validations
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
require 'base64'

module ForestAdminDatasourceCustomizer
module Decorators
module Binary
class BinaryHelper
def self.bin_to_hex(data)
data.unpack1('H*')
end

def self.hex_to_bin(data)
data.scan(/../).map(&:hex).pack('c*')
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ class DecoratorsStack
include ForestAdminDatasourceToolkit::Decorators

attr_reader :datasource, :schema, :search, :early_computed, :late_computed, :action, :relation, :late_op_emulate,
:early_op_emulate, :validation, :sort, :rename_field, :publication, :write, :chart, :hook, :segment
:early_op_emulate, :validation, :sort, :rename_field, :publication, :write, :chart, :hook, :segment,
:binary

def initialize(datasource)
@customizations = []
Expand All @@ -31,6 +32,7 @@ def initialize(datasource)
last = @write = Write::WriteDatasourceDecorator.new(last)
last = @hook = DatasourceDecorator.new(last, Hook::HookCollectionDecorator)
last = @validation = DatasourceDecorator.new(last, Validation::ValidationCollectionDecorator)
last = @binary = DatasourceDecorator.new(last, Binary::BinaryCollectionDecorator)

last = @publication = Publication::PublicationDatasourceDecorator.new(last)
last = @rename_field = DatasourceDecorator.new(last, RenameField::RenameFieldCollectionDecorator)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ def rename_field(current_name, new_name)

ForestAdminDatasourceToolkit::Validations::FieldValidator.validate_name(name, new_name)

# Revert previous renaming (avoids conflicts and need to recurse on @to_child_collection).
if to_child_collection[current_name]
child_name = to_child_collection[current_name]
to_child_collection.delete(current_name)
from_child_collection.delete(child_name)
initial_name = child_name
mark_all_schema_as_dirty
end

# Do not update arrays if renaming is a no-op (ie: customer is cancelling a previous rename)
return unless initial_name != new_name

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ module ForestAdminDatasourceCustomizer
fields: {
'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true, filter_operators: [Operators::EQUAL, Operators::IN]),
'title' => ColumnSchema.new(column_type: 'String', filter_operators: [Operators::EQUAL]),
'picture' => ColumnSchema.new(column_type: 'Binary'),
'reference' => ColumnSchema.new(column_type: 'String'),
'child_id' => ColumnSchema.new(column_type: 'Number', filter_operators: [Operators::EQUAL, Operators::IN]),
'author_id' => ColumnSchema.new(column_type: 'Number', is_read_only: true, is_sortable: true, filter_operators: [Operators::EQUAL, Operators::IN]),
Expand Down Expand Up @@ -499,5 +500,20 @@ module ForestAdminDatasourceCustomizer
expect(segment_collection.segments['foo_segment']).to eq(definition)
end
end

context 'when using replace_field_binary_mode' do
it 'replace binary mode on field' do
stack = @datasource_customizer.stack
stack.apply_queued_customizations({})
allow(@datasource_customizer.stack.binary.get_collection('book')).to receive(:set_binary_mode)
allow(stack.binary).to receive(:get_collection).with('book').and_return(@datasource_customizer.stack.binary.get_collection('book'))

customizer = described_class.new(@datasource_customizer, @datasource_customizer.stack, 'book')
customizer.replace_field_binary_mode('picture', 'datauri')
stack.apply_queued_customizations({})

expect(@datasource_customizer.stack.binary.get_collection('book')).to have_received(:set_binary_mode)
end
end
end
end
Loading

0 comments on commit 0e03047

Please sign in to comment.