Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/mxrb.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@
require_relative "mxrb/compiler/web_operation_compiler"
require_relative "mxrb/compiler/data_grid_bundle_compiler"
require_relative "mxrb/compiler/gallery_bundle_compiler"
require_relative "mxrb/compiler/image_bundle_compiler"
require_relative "mxrb/compiler/combo_box_bundle_compiler"
require_relative "mxrb/compiler/legacy_data_grid_compiler"
require_relative "mxrb/compiler/legacy_page_builder"
require_relative "mxrb/compiler/page_bundle_compiler"
Expand Down
273 changes: 273 additions & 0 deletions lib/mxrb/compiler/combo_box_bundle_compiler.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
# frozen_string_literal: true

require 'digest'
require 'json'

module Mxrb
module Compiler
# Compiles association- and database-backed instances of the official React Combo box widget.
class ComboBoxBundleCompiler # rubocop:disable Metrics/ClassLength
include ModelValues

WIDGET_ID = 'com.mendix.widget.web.combobox.Combobox'

def initialize(source, page_name, widget, scope:, entity:)
@source = source
@page_name = page_name
@widget = widget
@scope = scope
@entity = entity
@index = document_index
@values = property_values(widget['Object'])
@data_source = WebListDataSource.new(source, widget)
end

def supported?
widget_type&.fetch('WidgetId', nil) == WIDGET_ID && supported_source? &&
@data_source.supported? && !@data_source.entity.to_s.empty? && resolved_scope
end

def render # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
properties = primitive_properties.merge(
key: widget_key, '$widgetId': widget_key, class: css_class, id: widget_key,
optionsSourceStaticDataSource: [],
ariaRequired: raw(expression(false))
)
properties.merge!(source_properties)
control = "React.createElement($Combobox, #{javascript(properties)})"
caption = translated_text(@widget['LabelTemplate'])
group = {
key: "#{widget_key}$formGroup", '$widgetId': "#{widget_key}$formGroup",
class: "#{css_class} mx-combobox", control: raw("[#{control}]"),
width: 3, orientation: 'horizontal', labelFor: widget_key,
caption: raw(expression(caption)), hasError: raw(expression(false))
}
"React.createElement($FormGroup, #{javascript(group)})"
end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength

private

def supported_source?
return database_source_supported? if primitive('source') == 'database'

association_source_supported?
end

def database_source_supported?
target_attribute && database_caption_attribute && database_value_attribute
end

def association_source_supported?
primitive('source') == 'context' && primitive('optionsSourceType') == 'association' &&
association_step && association_caption_attribute
end

def source_properties
primitive('source') == 'database' ? database_properties : association_properties
end

def database_properties
return database_association_properties if target_steps.any?

{
databaseAttributeString: raw(attribute_property(target_attribute)),
optionsSourceDatabaseCaptionAttribute: raw(list_attribute_property(database_caption_attribute)),
optionsSourceDatabaseValueAttribute: raw(list_attribute_property(database_value_attribute)),
optionsSourceDatabaseDataSource: raw(list_property),
optionsSourceDatabaseItemSelection: raw(selection_property)
}
end

def database_association_properties
{
source: 'context',
optionsSourceAssociationCaptionAttribute: raw(list_attribute_property(database_caption_attribute)),
attributeAssociation: raw(association_property(target_steps.first)),
optionsSourceAssociationDataSource: raw(list_property)
}
end

def association_properties
{
optionsSourceAssociationCaptionAttribute: raw(list_attribute_property(association_caption_attribute)),
attributeAssociation: raw(association_property(association_step)),
optionsSourceAssociationDataSource: raw(list_property)
}
end

def association_property(step)
"AssociationProperty(#{javascript(
type: 'Reference', entity: resolved_entity, path: '', attribute: step['Association'],
endpointEntity: step['DestinationEntity'], selectableObjectsId: data_source_id,
scope: resolved_scope, onChange: do_nothing
)})"
end

def list_property
config = {
dataSourceId: data_source_id, entity: @data_source.entity, scope: resolved_scope,
operationId: WebOperationCompiler.operation_id(@page_name, @widget['Name'])
}
if @data_source.xpath?
"DatabaseObjectListProperty(#{javascript(config.merge(sort: []))})"
else
"MicroflowObjectListProperty(#{javascript(config.merge(argMap: {}, fetchOnlyWithAllParams: false))})"
end
end

def selection_property
selection = value('optionsSourceDatabaseItemSelection')&.fetch('Selection', 'Single') || 'Single'
"SelectionProperty(#{javascript(selectionType: selection, dataSourceId: data_source_id)})"
end

def attribute_property(attribute)
entity, name = split_attribute(attribute.fetch('Attribute'))
path = entity_steps(attribute).flat_map do |step|
[step['Association'], step['DestinationEntity']]
end.join('/')
config = {
scope: resolved_scope, path:, entity:, attribute: name, onChange: do_nothing,
isList: false, validation: nil, formatting: {}
}
"AttributeProperty(#{javascript(config)})"
end

def list_attribute_property(attribute)
entity, name = split_attribute(attribute.fetch('Attribute'))
"ListAttributeProperty(#{javascript(
path: '', entity:, attribute: name, attributeType: 'String', sortable: true,
filterable: true, dataSourceId: data_source_id, isList: false
)})"
end

def split_attribute(qualified)
entity, separator, name = qualified.to_s.rpartition('.')
raise CompilationError, "invalid Combo box attribute #{qualified.inspect}" unless separator == '.'

[entity, name]
end

def resolved_scope
@resolved_scope ||= begin
parameter = target_value&.dig('SourceVariable', 'PageParameter').to_s
parameter.empty? ? @scope : "$#{parameter}"
end
end

def resolved_entity
return @entity unless @entity.to_s.empty?

parameter = target_value&.dig('SourceVariable', 'PageParameter').to_s
page_parameter_entity(parameter)
end

def page_parameter_entity(name)
module_name, document_name = @page_name.split('.', 2)
page = @source.units_of('Forms$Page').find do |unit|
unit.module_name == module_name && unit.document['Name'] == document_name
end
parameter = array(page&.document&.fetch('Parameters', nil)).find { _1['Name'] == name }
parameter&.dig('ParameterType', 'Entity').to_s
end

def target_value = value('databaseAttributeString')
def target_attribute = target_value&.fetch('AttributeRef', nil)
def database_caption_attribute = value('optionsSourceDatabaseCaptionAttribute')&.fetch('AttributeRef', nil)
def database_value_attribute = value('optionsSourceDatabaseValueAttribute')&.fetch('AttributeRef', nil)
def association_caption_attribute = value('optionsSourceAssociationCaptionAttribute')&.fetch('AttributeRef', nil)

def association_step
entity_steps(value('attributeAssociation')).first
end

def target_steps = entity_steps(target_attribute)

def entity_steps(value)
array(value&.dig('EntityRef', 'Steps'))
end

def primitive_properties
@values.each_with_object({}) do |(key, (type, property)), result|
compiled = compile_primitive(type, property)
result[key.to_sym] = compiled unless compiled.nil?
end
end

def compile_primitive(type, property)
case type
when 'Boolean' then property['PrimitiveValue'] == 'true'
when 'Integer' then property['PrimitiveValue'].to_i
when 'Enumeration' then property['PrimitiveValue'].to_s
when 'TextTemplate' then raw(expression(translated_text(property['TextTemplate'])))
when 'Widgets' then [] if array(property['Widgets']).empty?
end
end

def translated_text(template)
items = array(template&.dig('Template', 'Items'))
items.find { _1['LanguageCode'] == 'en_US' }&.fetch('Text', '') ||
items.first&.fetch('Text', '') || ''
end

def expression(value)
"ExpressionProperty(#{javascript(expression: { expr: { type: 'literal', value: }, args: {} })})"
end

def do_nothing
{ type: 'doNothing', argMap: {}, config: {}, disabledDuringExecution: false }
end

def data_source_id = "p.#{Digest::SHA256.hexdigest(widget_key)[0, 6].to_i(16)}"
def widget_key = "p.#{@page_name}.#{@widget['Name']}"

def css_class
["mx-name-#{@widget['Name']}", @widget.dig('Appearance', 'Class')]
.map(&:to_s).reject(&:empty?).uniq.join(' ')
end

def primitive(key) = value(key)&.fetch('PrimitiveValue', nil)
def value(key) = @values[key]&.last
def raw(value) = { '$raw' => value }

def javascript(value)
return value['$raw'] if value.is_a?(Hash) && value.key?('$raw')
return "[#{value.map { javascript(_1) }.join(', ')}]" if value.is_a?(Array)
if value.is_a?(Hash)
return "{ #{value.map { |key, item| "#{JSON.generate(key)}: #{javascript(item)}" }.join(', ')} }"
end

JSON.generate(value)
end

def property_values(object)
array(object&.fetch('Properties', nil)).filter_map do |property|
type = @index[IO::BsonCodec.extract_id(property['TypePointer'])]
next unless type

[type.fetch('PropertyKey'), [type.dig('ValueType', 'Type'), property['Value']]]
end.to_h
end

def widget_type
object_type_id = IO::BsonCodec.extract_id(@widget.dig('Object', 'TypePointer'))
@index.values.find do |document|
IO::BsonCodec.extract_id(document.dig('ObjectType', '$ID')) == object_type_id
end
end

def document_index
{}.tap { |index| @source.documents.each { index_document(_1, index) } }
end

def index_document(value, index)
case value
when Hash
id = IO::BsonCodec.extract_id(value['$ID'])
index[id] = value if id
value.each_value { index_document(_1, index) }
when Array then value.each { index_document(_1, index) }
end
end
end # rubocop:enable Metrics/ClassLength
end
end
29 changes: 28 additions & 1 deletion lib/mxrb/compiler/database_connector_action_compiler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,25 @@ def compile(action)
}
end

def unconfigured_write?(action)
return false unless action&.fetch('$Type', nil) == 'DatabaseConnector$ExecuteDatabaseQueryAction'

connection, query = resolve_query(action['Query'].to_s)
return false if selecting_query?(query)

override = array(action['ConnectionParameterMappings']).find do |mapping|
mapping['ParameterName'].to_s.casecmp('DBSource').zero?
end
return false unless override.nil? || override['Value'].to_s.strip.empty?

constant_name = connection['ConnectionString'].to_s
constant = @source.units.find do |unit|
unit.document['$Type'] == 'Constants$Constant' &&
"#{unit.module_name}.#{unit.document['Name']}" == constant_name
end
constant && constant.document['DefaultValue'].to_s.empty?
end

private

def connection_index
Expand All @@ -54,7 +73,7 @@ def resolve_query(qualified)

def java_action_for(query)
sql = remove_comments(query['Query'].to_s).downcase
selecting = (sql.start_with?('select ') && !sql.include?(' into ')) || sql.start_with?('with ')
selecting = selecting_sql?(sql)
mapped = array(query['TableMappings']).any?
if selecting
return mapped ? 'ExternalDatabaseConnector.ExecuteQuery' : 'ExternalDatabaseConnector.ExecuteStatement'
Expand All @@ -69,6 +88,14 @@ def java_action_for(query)
end
end

def selecting_query?(query)
selecting_sql?(remove_comments(query['Query'].to_s).downcase)
end

def selecting_sql?(sql)
(sql.start_with?('select ') && !sql.include?(' into ')) || sql.start_with?('with ')
end

def statement_action(mapped)
mapped ? 'ExternalDatabaseConnector.ExecuteQuery' : 'ExternalDatabaseConnector.ExecuteStatement'
end
Expand Down
10 changes: 9 additions & 1 deletion lib/mxrb/compiler/domain_document_compiler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,18 @@ def compile_validation(source)
def compile_index(source)
{
'$ID' => source['$ID'], '$Type' => source['$Type'],
'Attributes' => array(source['Attributes']).map { plain_document(_1) },
'Attributes' => array(source['Attributes']).map { compile_indexed_attribute(_1) },
'GUID' => source['GUID'], 'IncludeInOffline' => source['IncludeInOffline'] == true
}
end

def compile_indexed_attribute(source)
result = plain_document(source)
result['AssociationPointer'] ||= BSON::Binary.new(
IO::BsonCodec.uuid_to_blob('00000000-0000-0000-0000-000000000000'), :generic
)
result
end
end
end
end
29 changes: 27 additions & 2 deletions lib/mxrb/compiler/gallery_bundle_compiler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ def initialize(source, page_name, widget)
@values = property_values(widget['Object'])
@data_source = WebListDataSource.new(source, widget)
@entity_name = data_source.entity
@xpath_arguments = resolve_xpath_arguments
end

def supported?
widget_type&.fetch('WidgetId', nil) == WIDGET_ID &&
@data_source.supported? && !entity_name.to_s.empty?
@data_source.supported? && !entity_name.to_s.empty? && !@xpath_arguments.nil?
end

def content_widgets = array(@values['content']&.last&.fetch('Widgets', nil))
Expand Down Expand Up @@ -56,7 +57,31 @@ def datasource
end

def xpath_datasource(config)
"DatabaseObjectListProperty(#{js_object(config.merge(entity: entity_name, sort: []))})"
values = config.merge(entity: entity_name, sort: [])
unless @xpath_arguments.empty?
values[:arguments] = @xpath_arguments
values[:fetchOnlyWithAllParams] = true
end
"DatabaseObjectListProperty(#{js_object(values)})"
end

def resolve_xpath_arguments
return {} unless @data_source.xpath?

names = xpath_variables(@data_source.xpath_constraint)
return {} if names.empty?

parameters = object_page_parameters(page_document, names)
return unless parameters

parameters.to_h { |name, _entity| [name, ["$#{name}", :undefined, false]] }
end

def page_document
module_name, document_name = @page_name.split('.', 2)
@source.units_of('Forms$Page').find do |unit|
unit.module_name == module_name && unit.document['Name'] == document_name
end&.document
end

def microflow_datasource(config)
Expand Down
Loading