mxrb 0.1.2 → 0.1.3

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.
@@ -7,6 +7,40 @@ module Mxrb
7
7
  # $Type: Projects$Module
8
8
  # ContainmentName: "Modules"
9
9
  class Module < Unit
10
+ INFRASTRUCTURE_DOCUMENT_ROUTES = {
11
+ 'ExportMappings$ExportMapping' => 'mappings/exports',
12
+ 'ImportMappings$ImportMapping' => 'mappings/imports',
13
+ 'JsonStructures$JsonStructure' => 'mappings/json_structures',
14
+ 'MessageDefinitions$MessageDefinitionCollection' => 'mappings/message_definitions',
15
+ 'MessageDefinitions$MessageDefinition2' => 'mappings/message_definitions',
16
+ 'XmlSchemas$XmlSchema' => 'mappings/xml_schemas',
17
+ 'Rest$PublishedRestService' => 'endpoints',
18
+ 'WebServices$PublishedService' => 'endpoints',
19
+ 'WebServices$PublishedWebService' => 'endpoints',
20
+ 'ODataPublish$PublishedODataService' => 'endpoints',
21
+ 'ODataPublish$PublishedODataService2' => 'endpoints',
22
+ 'Rest$ConsumedRestService' => 'integrations',
23
+ 'Rest$ConsumedODataService' => 'integrations',
24
+ 'AppServices$ConsumedAppService' => 'integrations',
25
+ 'ODataImport$ConsumedODataService' => 'integrations'
26
+ }.freeze
27
+ MAPPING_DOCUMENT_TYPES = INFRASTRUCTURE_DOCUMENT_ROUTES.keys.grep(
28
+ /Mappings|JsonStructures|MessageDefinitions|XmlSchemas/
29
+ ).freeze
30
+ APPLICATION_DOCUMENT_ROUTES = {
31
+ 'DataSets$DataSet' => 'queries/datasets',
32
+ 'ScheduledEvents$ScheduledEvent' => 'jobs/scheduled_events'
33
+ }.freeze
34
+ DOMAIN_DOCUMENT_ROUTES = {
35
+ 'DomainModels$ViewEntitySourceDocument' => 'oql_views',
36
+ 'Enumerations$Enumeration' => 'enumerations',
37
+ 'Constants$Constant' => 'constants'
38
+ }.freeze
39
+ EDITABLE_DOCUMENT_TYPES = (
40
+ INFRASTRUCTURE_DOCUMENT_ROUTES.keys + APPLICATION_DOCUMENT_ROUTES.keys +
41
+ DOMAIN_DOCUMENT_ROUTES.keys
42
+ ).freeze
43
+
10
44
  attr_reader :name, :sort_index, :from_app_store,
11
45
  :app_store_guid, :app_store_version, :export_level
12
46
 
@@ -84,6 +118,28 @@ module Mxrb
84
118
  .map { unit_to_doc(_1) }
85
119
  end
86
120
 
121
+ def mapping_documents
122
+ infrastructure_documents.select { MAPPING_DOCUMENT_TYPES.include?(_1[:type]) }
123
+ end
124
+
125
+ def infrastructure_documents
126
+ @infrastructure_documents ||= routed_documents(INFRASTRUCTURE_DOCUMENT_ROUTES)
127
+ end
128
+
129
+ def application_documents
130
+ @application_documents ||= routed_documents(APPLICATION_DOCUMENT_ROUTES)
131
+ end
132
+
133
+ def domain_documents
134
+ @domain_documents ||= routed_documents(DOMAIN_DOCUMENT_ROUTES)
135
+ end
136
+
137
+ def oql_view_documents
138
+ @oql_view_documents ||= domain_documents.select do |document|
139
+ document[:type] == 'DomainModels$ViewEntitySourceDocument'
140
+ end
141
+ end
142
+
87
143
  def module_roles
88
144
  @module_roles ||= begin
89
145
  raw = @mpr.children_of(@id).find { _1["ContainmentName"] == "ModuleSecurity" }
@@ -109,6 +165,21 @@ module Mxrb
109
165
  @mpr.parse_contents(unit_hash[:raw])
110
166
  end
111
167
 
168
+ def routed_documents(routes)
169
+ document_units.filter_map do |unit|
170
+ route = routes[unit[:type]]
171
+ next unless route
172
+
173
+ raw = unit.fetch(:raw)
174
+ doc = @mpr.parse_contents(raw)
175
+ {
176
+ id: raw.fetch("UnitID"), container_id: raw.fetch("ContainerID"),
177
+ containment: raw.fetch("ContainmentName"), type: unit.fetch(:type),
178
+ name: doc["Name"] || doc["name"] || raw.fetch("UnitID"), doc:, route:
179
+ }
180
+ end
181
+ end
182
+
112
183
  # Documents live in ContainmentName = "Documents" recursively under this module.
113
184
  # We do a simple two-pass: direct Documents children + Documents inside Folders.
114
185
  def document_units
@@ -122,6 +122,7 @@ module Mxrb
122
122
  def install(staged_files, protected_files = [])
123
123
  staged_files.each do |relative, source|
124
124
  install_file(relative, source) unless protected_files.include?(relative)
125
+ yield(relative) if block_given?
125
126
  end
126
127
  end
127
128
 
@@ -179,9 +180,12 @@ module Mxrb
179
180
  end
180
181
 
181
182
  def import!
182
- validate_inputs!
183
- Dir.mktmpdir('mxrb-module-import-') do |temporary|
184
- Zip::File.open(@package_path) { import_archive(_1, temporary) }
183
+ Progress.with("Importing #{File.basename(@package_path)}") do |progress|
184
+ validate_inputs!
185
+ progress.update(detail: 'reading package')
186
+ Dir.mktmpdir('mxrb-module-import-') do |temporary|
187
+ Zip::File.open(@package_path) { import_archive(_1, temporary, progress) }
188
+ end
185
189
  end
186
190
  rescue Zip::Error, REXML::ParseException => e
187
191
  raise MarketplaceError, "invalid Mendix module package: #{e.message}"
@@ -189,12 +193,12 @@ module Mxrb
189
193
 
190
194
  private
191
195
 
192
- def import_archive(archive, temporary)
196
+ def import_archive(archive, temporary, progress)
193
197
  reader = ModulePackageReader.new(archive)
194
198
  descriptor = reader.descriptor
195
199
  source_path = reader.extract_project(descriptor, temporary)
196
200
  staged_files = reader.stage_files(descriptor.files, temporary)
197
- import_project(source_path, descriptor, staged_files, temporary)
201
+ import_project(source_path, descriptor, staged_files, temporary, progress)
198
202
  end
199
203
 
200
204
  def validate_inputs!
@@ -203,12 +207,13 @@ module Mxrb
203
207
  raise MarketplaceError, "target root not found: #{@target_root}" unless File.directory?(@target_root)
204
208
  end
205
209
 
206
- def import_project(source_path, descriptor, staged_files, temporary) # rubocop:disable Metrics/MethodLength
210
+ def import_project(source_path, descriptor, staged_files, temporary, progress) # rubocop:disable Metrics/MethodLength
207
211
  source = IO::MprFile.open(source_path, readonly: true)
208
212
  target = IO::MprFile.open(@mpr_path)
209
213
  assets = ModulePackageAssets.new(@target_root, temporary)
210
214
  imported_ids = []
211
- import_transaction(source, target, descriptor, staged_files, assets, imported_ids)
215
+ import_transaction(source, target, descriptor, staged_files, assets, imported_ids, progress)
216
+ progress.advance(detail: 'import transaction')
212
217
  import_result(source, target, descriptor, staged_files, imported_ids)
213
218
  rescue StandardError
214
219
  assets&.rollback
@@ -220,24 +225,30 @@ module Mxrb
220
225
  end
221
226
 
222
227
  # rubocop:disable Metrics/ParameterLists
223
- def import_transaction(source, target, descriptor, staged_files, assets, imported_ids)
228
+ def import_transaction(source, target, descriptor, staged_files, assets, imported_ids, progress) # rubocop:disable Metrics/MethodLength
224
229
  validate_versions!(source, target, descriptor)
225
230
  module_unit, units = package_units(source, descriptor.name)
226
231
  validate_target!(target, descriptor.name, units)
232
+ progress.update(
233
+ current: 2, total: units.size + staged_files.size + 3,
234
+ detail: "#{units.size} model units"
235
+ )
227
236
  target.transaction do
228
- imported_ids.concat(insert_units(source, target, module_unit, units))
229
- assets.install(staged_files, @protected_files)
237
+ imported_ids.concat(insert_units(source, target, module_unit, units, progress))
238
+ assets.install(staged_files, @protected_files) do |relative|
239
+ progress.advance(detail: "asset #{relative}")
240
+ end
230
241
  end
231
242
  end
232
243
  # rubocop:enable Metrics/ParameterLists
233
244
 
234
- def insert_units(source, target, module_unit, units)
245
+ def insert_units(source, target, module_unit, units, progress)
235
246
  units.map do |unit|
236
247
  container = unit == module_unit ? target.root_unit.fetch('UnitID') : unit.fetch('ContainerID')
237
248
  target.insert_unit(
238
249
  container_uuid: container, containment_name: unit.fetch('ContainmentName'),
239
250
  contents_doc: source.parse_contents(unit)
240
- )
251
+ ).tap { progress.advance(detail: "unit #{unit.fetch('UnitID')}") }
241
252
  end
242
253
  end
243
254
 
@@ -200,17 +200,21 @@ module Mxrb
200
200
  end
201
201
 
202
202
  def install(archive, package)
203
- validate_target!
204
- inventory = WidgetPackageInventory.read(archive)
205
- validate_identity!(inventory, package)
206
- digest = Digest::SHA256.file(archive).hexdigest
207
- destination = safe_path(File.join('widgets', inventory.project_filename))
208
- cache = safe_path(cache_relative(inventory, package))
209
- lock_path = safe_path(File.join('.mxrb', 'marketplace.lock.json'))
210
- current = validate_boundaries!(package, destination, cache, lock_path)
211
- install_transaction(
212
- archive, package, inventory, digest, destination, cache, lock_path, current
213
- )
203
+ Progress.with("Installing widget #{package.name}") do |progress|
204
+ validate_target!
205
+ progress.update(detail: 'reading widget package')
206
+ inventory = WidgetPackageInventory.read(archive)
207
+ validate_identity!(inventory, package)
208
+ digest = Digest::SHA256.file(archive).hexdigest
209
+ destination = safe_path(File.join('widgets', inventory.project_filename))
210
+ cache = safe_path(cache_relative(inventory, package))
211
+ lock_path = safe_path(File.join('.mxrb', 'marketplace.lock.json'))
212
+ current = validate_boundaries!(package, destination, cache, lock_path)
213
+ progress.update(detail: 'installing project assets')
214
+ install_transaction(
215
+ archive, package, inventory, digest, destination, cache, lock_path, current
216
+ )
217
+ end
214
218
  end
215
219
 
216
220
  private
@@ -223,14 +223,20 @@ module Mxrb
223
223
  end
224
224
 
225
225
  def json(url, authorization: default_authorization)
226
- JSON.parse(get(url, accept: 'application/json', authorization:))
226
+ Progress.with('Loading Marketplace data') do |progress|
227
+ progress.update(detail: URI.parse(url).host)
228
+ JSON.parse(get(url, accept: 'application/json', authorization:))
229
+ end
227
230
  rescue JSON::ParserError => e
228
231
  raise MarketplaceError, "invalid JSON response: #{e.message}"
229
232
  end
230
233
 
231
234
  def download(url, destination, authorization: default_authorization)
232
- File.binwrite(destination, get(url, accept: 'application/octet-stream', authorization:))
233
- destination
235
+ Progress.with("Downloading #{File.basename(destination)}") do |progress|
236
+ progress.update(detail: URI.parse(url).host)
237
+ File.binwrite(destination, get(url, accept: 'application/octet-stream', authorization:))
238
+ destination
239
+ end
234
240
  end
235
241
 
236
242
  private
data/lib/mxrb/oql.rb CHANGED
@@ -49,7 +49,7 @@ module Mxrb
49
49
 
50
50
  # Recursive BSON discovery is intentionally kept together so that its
51
51
  # source-type guard and ownership context cannot diverge.
52
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
52
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
53
53
  def discover(document, raw, module_name)
54
54
  found = []
55
55
  walk(document) do |node, path, ancestors|
@@ -59,6 +59,11 @@ module Mxrb
59
59
  node['Query'], raw, module_name, path + ['Query'], source_type, ancestors
60
60
  )
61
61
  end
62
+ if source_type == 'DomainModels$ViewEntitySourceDocument' && node['Oql'].is_a?(String)
63
+ found << build_query(
64
+ node['Oql'], raw, module_name, path + ['Oql'], source_type, ancestors
65
+ )
66
+ end
62
67
  node.each do |key, value|
63
68
  next unless key.to_s.match?(/\AOqlQuery\z/i) && value.is_a?(String)
64
69
 
@@ -67,7 +72,7 @@ module Mxrb
67
72
  end
68
73
  found.uniq { [_1.unit_id, _1.path] }
69
74
  end
70
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
75
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
71
76
 
72
77
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
73
78
  def walk(node, path = [], ancestors = [], &block)
@@ -97,7 +102,8 @@ module Mxrb
97
102
  unit_type = @project.parse_bson(raw)['$Type'].to_s
98
103
  kind = if unit_type == 'DataSets$DataSet'
99
104
  :dataset
100
- elsif owner['$Type'].to_s.match?(/Entity/i)
105
+ elsif unit_type == 'DomainModels$ViewEntitySourceDocument' ||
106
+ owner['$Type'].to_s.match?(/Entity/i)
101
107
  :view_entity
102
108
  else
103
109
  :oql
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+ require 'singleton'
5
+
6
+ module Mxrb
7
+ # Terminal progress rendering shared by every long-running MXRB operation.
8
+ # It is enabled automatically only for an interactive terminal, writes to
9
+ # stderr, and therefore never contaminates command output or JSON on stdout.
10
+ module Progress
11
+ THREAD_KEY = :mxrb_progress_task
12
+ FALSE_VALUES = %w[0 false no off].freeze
13
+
14
+ # No-op object returned when progress rendering is disabled.
15
+ class NullTask
16
+ include Singleton
17
+
18
+ def start = self
19
+ def update(**) = self
20
+ def advance(*, **) = self
21
+ def add_total(*) = self
22
+ def finish(*) = self
23
+ def fail(*) = self
24
+ def enabled? = false
25
+ end
26
+
27
+ # Thread-safe terminal renderer for a single operation.
28
+ class Task # rubocop:disable Metrics/ClassLength
29
+ SPINNER = %w[| / - \\].freeze
30
+ BAR_WIDTH = 28
31
+ REFRESH_INTERVAL = 0.08
32
+
33
+ attr_reader :label, :total, :current
34
+
35
+ def initialize(label, total: nil, io: $stderr)
36
+ @label = label.to_s
37
+ @total = normalize_total(total)
38
+ @io = io
39
+ @current = 0
40
+ @detail = nil
41
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
42
+ @last_rendered_at = 0.0
43
+ @spinner_index = 0
44
+ @mutex = Mutex.new
45
+ @finished = false
46
+ end
47
+
48
+ def enabled? = true
49
+
50
+ def start
51
+ render(force: true)
52
+ start_spinner unless determinate?
53
+ self
54
+ end
55
+
56
+ def update(current: nil, total: nil, detail: nil, force: false)
57
+ became_determinate = false
58
+ @mutex.synchronize do
59
+ became_determinate = @total.nil? && !total.nil?
60
+ @total = normalize_total(total) unless total.nil?
61
+ @current = [[Integer(current), 0].max, @total || Float::INFINITY].min unless current.nil?
62
+ @detail = detail.to_s unless detail.nil?
63
+ end
64
+ stop_spinner if became_determinate
65
+ render(force:)
66
+ self
67
+ end
68
+
69
+ def advance(amount = 1, detail: nil, force: false)
70
+ @mutex.synchronize do
71
+ @current += amount
72
+ @current = [@current, @total].min if @total
73
+ @detail = detail.to_s unless detail.nil?
74
+ end
75
+ render(force:)
76
+ self
77
+ end
78
+
79
+ def add_total(amount)
80
+ @mutex.synchronize { @total = (@total || 0) + Integer(amount) }
81
+ render(force: true)
82
+ self
83
+ end
84
+
85
+ def finish(detail = nil)
86
+ stop_spinner
87
+ @mutex.synchronize do
88
+ return self if @finished
89
+
90
+ @detail = detail.to_s if detail
91
+ @current = @total if @total
92
+ @finished = true
93
+ end
94
+ render(force: true, final: true)
95
+ self
96
+ end
97
+
98
+ def fail(message = nil)
99
+ stop_spinner
100
+ @mutex.synchronize do
101
+ return self if @finished
102
+
103
+ @detail = message.to_s unless message.to_s.empty?
104
+ @finished = true
105
+ end
106
+ render(force: true, final: true, failed: true)
107
+ self
108
+ end
109
+
110
+ private
111
+
112
+ def normalize_total(value)
113
+ return nil if value.nil?
114
+
115
+ [Integer(value), 1].max
116
+ end
117
+
118
+ def determinate? = !@total.nil?
119
+
120
+ def start_spinner
121
+ return unless dynamic_terminal?
122
+
123
+ @spinner_thread = Thread.new do
124
+ loop do
125
+ sleep REFRESH_INTERVAL
126
+ break if @mutex.synchronize { @finished }
127
+
128
+ render(force: true)
129
+ end
130
+ end
131
+ end
132
+
133
+ def stop_spinner
134
+ thread = @spinner_thread
135
+ return unless thread
136
+
137
+ @mutex.synchronize { @finished = true }
138
+ thread.join
139
+ @spinner_thread = nil
140
+ @mutex.synchronize { @finished = false }
141
+ end
142
+
143
+ def render(force: false, final: false, failed: false) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
144
+ line = nil
145
+ @mutex.synchronize do
146
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
147
+ return if !force && !final && now - @last_rendered_at < REFRESH_INTERVAL
148
+
149
+ @last_rendered_at = now
150
+ line = rendered_line(now, failed:)
151
+ end
152
+ prefix = dynamic_terminal? ? "\r\e[2K" : ''
153
+ suffix = final || !dynamic_terminal? ? "\n" : ''
154
+ @io.write("#{prefix}#{truncate(line)}#{suffix}")
155
+ @io.flush if @io.respond_to?(:flush)
156
+ rescue IOError, Errno::EPIPE
157
+ nil
158
+ end
159
+
160
+ def rendered_line(now, failed:)
161
+ detail = @detail.to_s.empty? ? '' : " - #{@detail}"
162
+ elapsed = format('%.1fs', now - @started_at)
163
+ return "[mxrb] [FAILED] #{@label}#{detail} (#{elapsed})" if failed
164
+
165
+ determinate_line(detail, elapsed) || spinner_line(detail, elapsed)
166
+ end
167
+
168
+ def determinate_line(detail, elapsed)
169
+ return unless determinate?
170
+
171
+ ratio = [@current.fdiv(@total), 1.0].min
172
+ filled = (ratio * BAR_WIDTH).round
173
+ bar = '#' * filled + '-' * (BAR_WIDTH - filled)
174
+ "[mxrb] [#{bar}] #{format('%3d%%', ratio * 100)} #{@label}#{detail} (#{elapsed})"
175
+ end
176
+
177
+ def spinner_line(detail, elapsed)
178
+ frame = SPINNER[@spinner_index % SPINNER.length]
179
+ @spinner_index += 1
180
+ "[mxrb] [#{frame}] #{@label}#{detail} (#{elapsed})"
181
+ end
182
+
183
+ def dynamic_terminal?
184
+ @io.respond_to?(:tty?) && @io.tty?
185
+ end
186
+
187
+ def truncate(line)
188
+ width = @io.respond_to?(:winsize) ? @io.winsize.last : 100
189
+ width = 100 unless width.to_i.positive?
190
+ return line if line.length <= width
191
+
192
+ "#{line[0, width - 3]}..."
193
+ rescue IOError, Errno::ENOTTY
194
+ line
195
+ end
196
+ end # rubocop:enable Metrics/ClassLength
197
+
198
+ class << self
199
+ attr_writer :io
200
+
201
+ def configure(enabled: nil, io: nil)
202
+ @enabled = enabled unless enabled.nil?
203
+ @io = io if io
204
+ end
205
+
206
+ def reset!
207
+ @enabled = nil
208
+ @io = nil
209
+ end
210
+
211
+ def enabled?
212
+ return @enabled unless @enabled.nil?
213
+ return false if FALSE_VALUES.include?(ENV.fetch('MXRB_PROGRESS', '').downcase)
214
+
215
+ output.respond_to?(:tty?) && output.tty?
216
+ end
217
+
218
+ def with(label, total: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
219
+ active = Thread.current[THREAD_KEY]
220
+ return yield(active) if active
221
+ return yield(NullTask.instance) unless enabled?
222
+
223
+ task = Task.new(label, total:, io: output).start
224
+ Thread.current[THREAD_KEY] = task
225
+ result = yield(task)
226
+ task.finish
227
+ result
228
+ rescue StandardError => e
229
+ task&.fail(e.message)
230
+ raise
231
+ ensure
232
+ Thread.current[THREAD_KEY] = nil if defined?(task) && task
233
+ end
234
+
235
+ def current = Thread.current[THREAD_KEY] || NullTask.instance
236
+
237
+ private
238
+
239
+ def output = @io || $stderr
240
+ end
241
+ end
242
+ end
@@ -161,7 +161,7 @@ module Mxrb
161
161
 
162
162
  # Git transport for Team Server. PATs are passed to a short-lived
163
163
  # GIT_ASKPASS process and are never embedded in a URL or git config.
164
- class Repository
164
+ class Repository # rubocop:disable Metrics/ClassLength
165
165
  def initialize(credentials: Credentials.new, runner: CommandRunner.new,
166
166
  authenticator: GitAuthenticator.new(credentials))
167
167
  @runner = runner
@@ -249,11 +249,14 @@ module Mxrb
249
249
  end
250
250
 
251
251
  def capture!(command, chdir: nil)
252
- @authenticator.call do |environment|
253
- output, status = @runner.capture(environment, command, chdir:)
254
- raise TeamServerError, "Team Server Git operation failed: #{output.strip}" unless status.success?
255
-
256
- output
252
+ Progress.with("Team Server #{command.first(2).join(' ')}") do |progress|
253
+ progress.update(detail: chdir || 'remote repository')
254
+ @authenticator.call do |environment|
255
+ output, status = @runner.capture(environment, command, chdir:)
256
+ raise TeamServerError, "Team Server Git operation failed: #{output.strip}" unless status.success?
257
+
258
+ output
259
+ end
257
260
  end
258
261
  end
259
262
 
@@ -285,7 +288,7 @@ module Mxrb
285
288
  mpr
286
289
  end
287
290
  end
288
- end
291
+ end # rubocop:enable Metrics/ClassLength
289
292
 
290
293
  # Read-only client for Mendix's official App Repository API.
291
294
  class Api
data/lib/mxrb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mxrb
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.3"
5
5
  end