diff --git a/Gemfile.sqlite-vec.lock b/Gemfile.sqlite-vec.lock index ba73fa6..73f05f2 100644 --- a/Gemfile.sqlite-vec.lock +++ b/Gemfile.sqlite-vec.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - mxrb (0.1.2) + mxrb (0.1.3) base64 (~> 0.2) bigdecimal (~> 3.1) bson (~> 5.2) @@ -126,7 +126,7 @@ CHECKSUMS json (2.21.1) sha256=13a43df75d95641443f5702dff350f237164a9d811ff0f2c2800d4d980220583 language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 - mxrb (0.1.2) + mxrb (0.1.3) onnxruntime (0.11.5-arm64-darwin) sha256=9f2f45b3ac16999c466ac352562195e03a011cc930b8750c0b1e43a49c1ca5d9 onnxruntime (0.11.5-x86_64-darwin) sha256=19c18e90eb101f69a3232dfdd1742e77d69e2df623b106b670221629d4b4cc15 onnxruntime (0.11.5-x86_64-linux) sha256=13f20de606a8fb32ba404bca9e1fc47f0eab4605c909252dbcf6db4fb106c9e6 diff --git a/bin/mxrb b/bin/mxrb index 71098cb..ab4fdab 100755 --- a/bin/mxrb +++ b/bin/mxrb @@ -64,7 +64,9 @@ end SCAFFOLD_COMMANDS = (Mxrb::Scaffold::Help::COMMANDS.keys - ["design"]).freeze +progress_disabled = ARGV.delete("--no-progress") command = ARGV.shift +Mxrb::Progress.configure(enabled: false) if progress_disabled || ARGV.include?("--json") case command when '--version', '-v' @@ -1506,6 +1508,7 @@ else mxrb — Pure-Ruby Mendix .mpr toolkit Commands: + --no-progress Disable interactive progress rendering --version, -v Show the installed MXRB version doctor [DIR] [--json] Check project files and local toolchain benchmark Measure open, index and validation time diff --git a/docs/pt-BR/entity-dsl.md b/docs/pt-BR/entity-dsl.md index c6f124c..cfb2e69 100644 --- a/docs/pt-BR/entity-dsl.md +++ b/docs/pt-BR/entity-dsl.md @@ -94,6 +94,12 @@ entity :AnimalSearchResult do string :Name end +entity :AnimalSummaryView do + oql_view source: "VetClinic.AnimalSummarySource" + string :Name + integer :Total +end + entity :Animal do before_commit microflow: "VetClinic.VAL_Animal" after_commit microflow: "VetClinic.ACT_AfterAnimalCommit" @@ -111,6 +117,8 @@ end Em `access_rule`, `read:` e `write:` aceitam `:all`, `:none` ou uma lista de atributos. Declare um ou mais papéis qualificados como primeiros argumentos. +`oql_view` liga a entidade ao `ViewEntitySourceDocument`; ao exportar um projeto +existente, a consulta OQL aparece junto da entidade em `domain/oql_views`. Use `mxrb entity --help` para o comando e `mxrb generate project.rb` seguido de `mxrb validate App.mpr` para validar o resultado. diff --git a/docs/pt-BR/writing.md b/docs/pt-BR/writing.md index 88588a4..545379c 100644 --- a/docs/pt-BR/writing.md +++ b/docs/pt-BR/writing.md @@ -5,6 +5,12 @@ `mxrb generate` avalia uma definição Ruby e cria ou atualiza o MPR. Nomes Mendix são chaves estáveis, portanto reaplicar a definição não duplica units. +Operações demoradas exibem progresso em `stderr` quando executadas em um +terminal interativo. Quando o total é conhecido, o MXRB mostra barra e +porcentagem; operações externas sem total confiável mostram spinner e tempo +decorrido. Pipes, redirecionamentos e saídas `--json` permanecem limpos. Use +`--no-progress` ou `MXRB_PROGRESS=0` para desativar explicitamente. + Para iniciar em uma pasta vazia: ```sh @@ -122,6 +128,28 @@ usam `bson_binary`. Alterações nesse Ruby sobrepõem o baseline antes das escr tipadas. Corpos de flow possuem `body_fingerprint`: o grafo nativo é reutilizado quando o Ruby não mudou e regenerado após uma edição. +Export mappings, import mappings e JSON structures são materializados por +módulo em `infrastructure/mappings/{exports,imports,json_structures}`. Esses +arquivos mantêm `UnitID` e container/pasta originais, podem editar toda a +estrutura profunda em Ruby e têm precedência sobre o baseline durante o +`generate`. + +Serviços REST, OData e web services publicados são exportados para +`infrastructure/endpoints`; recursos, operações, métodos HTTP, paths, +parâmetros e referências a microflows permanecem no mesmo arquivo Ruby. +Serviços consumidos são roteados para `infrastructure/integrations`, enquanto +message definitions e XML schemas ficam em `infrastructure/mappings`. + +O Domain Model exportado separa entidades persistentes em `domain/entities`, +DTOs/non-persistent em `domain/dtos` e entidades de OQL View em +`domain/oql_views`. O arquivo da OQL View também contém seu +`DomainModels$ViewEntitySourceDocument`, portanto a consulta `Oql` pode ser +editada no mesmo Ruby sem perder a referência da entidade. Datasets OQL ficam +em `application/queries/datasets`; enumerations e constants ficam em +`domain/enumerations` e `domain/constants`. Scheduled events ficam em +`application/jobs/scheduled_events`. O `domain/model.rb` e o +`application/application.rb` carregam automaticamente todas essas categorias. + A DSL cobre criação/alteração/retrieve/commit/delete, chamadas de microflow, Java, JavaScript, nanoflow e app service, páginas, REST, listas, decisões, loops, eventos de erro e rescue. diff --git a/lib/mxrb.rb b/lib/mxrb.rb index f1fe11e..ee55691 100644 --- a/lib/mxrb.rb +++ b/lib/mxrb.rb @@ -2,6 +2,7 @@ require_relative "mxrb/version" require_relative "mxrb/errors" +require_relative "mxrb/progress" require_relative "mxrb/doctor" require_relative "mxrb/benchmark" require_relative "mxrb/project_lifecycle" @@ -139,14 +140,18 @@ module Mxrb # # Mxrb.open("MyApp.mpr") { |p| puts p.modules.map(&:name) } # - def self.open(path, readonly: true, &block) - project = Model::Project.open(path, readonly: readonly) - return project unless block + def self.open(path, readonly: true, &block) # rubocop:disable Metrics/MethodLength + return Model::Project.open(path, readonly: readonly) unless block - begin - block.call(project) - ensure - project.close + Progress.with("Loading #{File.basename(path)}") do |progress| + progress.update(detail: "opening model") + project = Model::Project.open(path, readonly: readonly) + begin + progress.update(detail: "processing model") + block.call(project) + ensure + project.close + end end end diff --git a/lib/mxrb/compiler/packager.rb b/lib/mxrb/compiler/packager.rb index b0fa8f0..4de4f63 100644 --- a/lib/mxrb/compiler/packager.rb +++ b/lib/mxrb/compiler/packager.rb @@ -19,16 +19,24 @@ def initialize(mpr_path, deployment: nil) @deployment = File.expand_path(deployment || File.join(@project_root, 'deployment')) end - def pack(output:, force: false) - output_path = File.expand_path(output) - validate_input!(output_path, force:) - version = Mxrb.open(@mpr_path, &:mendix_version) - adapter = Adapter.for(version) - metadata = adapter.validate_deployment!(@deployment) - adapter.validate_freshness!(@mpr_path, @deployment) - files, directories = inventory - write_atomically(output_path, files, directories) - package_result(output_path, version, metadata) + def pack(output:, force: false) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + Progress.with("Packing #{File.basename(@mpr_path)}") do |progress| + output_path = File.expand_path(output) + validate_input!(output_path, force:) + version = Mxrb.open(@mpr_path, &:mendix_version) + adapter = Adapter.for(version) + metadata = adapter.validate_deployment!(@deployment) + adapter.validate_freshness!(@mpr_path, @deployment) + files, directories = inventory + progress.update( + current: 3, total: files.size + directories.size + 4, + detail: "#{files.size} files" + ) + write_atomically(output_path, files, directories, progress) + package_result(output_path, version, metadata).tap do + progress.advance(detail: 'package checksum') + end + end end private @@ -69,13 +77,19 @@ def relative(path) path.delete_prefix("#{@deployment}/").tr('\\', '/') end - def write_atomically(output, files, directories) + def write_atomically(output, files, directories, progress) # rubocop:disable Metrics/MethodLength FileUtils.mkdir_p(File.dirname(output)) Dir.mktmpdir('mxrb-mda-', File.dirname(output)) do |tmpdir| temporary = File.join(tmpdir, 'package.mda') Zip::File.open(temporary, create: true) do |archive| - directories.each { add_directory(archive, _1) } - files.each { add_file(archive, _1) } + directories.each do |directory| + add_directory(archive, directory) + progress.advance(detail: relative(directory)) + end + files.each do |file| + add_file(archive, file) + progress.advance(detail: relative(file)) + end end FileUtils.mv(temporary, output, force: true) end diff --git a/lib/mxrb/compiler/portable_packager.rb b/lib/mxrb/compiler/portable_packager.rb index a648ec2..e5e376c 100644 --- a/lib/mxrb/compiler/portable_packager.rb +++ b/lib/mxrb/compiler/portable_packager.rb @@ -309,17 +309,22 @@ def initialize(mpr_path, deployment: nil, mendix_home: nil) @mendix_home = mendix_home && File.expand_path(mendix_home) end - def pack(output:, force: false) - output_path = File.expand_path(output) - validate_output!(output_path, force:) - version = Mxrb.open(@mpr_path, &:mendix_version) - adapter = Adapter.for(version) - metadata = adapter.validate_deployment!(@deployment) - adapter.validate_freshness!(@mpr_path, @deployment) - runtime = runtime_root(version) - validate_runtime!(runtime) - PortableArchiveWriter.new(@deployment, runtime, metadata).write(output_path) - result(output_path, version, metadata) + def pack(output:, force: false) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + Progress.with("Packing portable Runtime for #{File.basename(@mpr_path)}") do |progress| + output_path = File.expand_path(output) + validate_output!(output_path, force:) + progress.update(detail: 'validating deployment') + version = Mxrb.open(@mpr_path, &:mendix_version) + adapter = Adapter.for(version) + metadata = adapter.validate_deployment!(@deployment) + adapter.validate_freshness!(@mpr_path, @deployment) + runtime = runtime_root(version) + validate_runtime!(runtime) + progress.update(detail: 'writing Runtime archive') + PortableArchiveWriter.new(@deployment, runtime, metadata).write(output_path) + progress.update(detail: 'calculating checksum') + result(output_path, version, metadata) + end end private diff --git a/lib/mxrb/dsl/builder.rb b/lib/mxrb/dsl/builder.rb index 8f31696..8c6b37f 100644 --- a/lib/mxrb/dsl/builder.rb +++ b/lib/mxrb/dsl/builder.rb @@ -872,15 +872,21 @@ def scheduled_event(name, microflow:, interval: 1, unit: :days, enabled: true, & @scheduled_events << sb.to_h end - def native_document(name, type:, deep_structure:, containment: 'Documents') + def native_document(name, type:, deep_structure:, containment: 'Documents', + unit_id: nil, container_id: nil) raise ArgumentError, 'deep_structure requires a Hash' unless deep_structure.is_a?(Hash) @native_documents << { name: name.to_s, type: type.to_s, containment: containment.to_s, + unit_id: unit_id&.to_s, container_id: container_id&.to_s, doc: { '$Type' => type.to_s, 'Name' => name.to_s }.merge(deep_structure) } end + def bson_binary(base64, subtype: :generic) + BSON::Binary.new(Base64.strict_decode64(base64), subtype.to_sym) + end + # Native responsive application shell. It deliberately uses only Mendix # core widgets, so generated projects do not depend on Atlas Core merely # to display their navigation. @@ -1070,6 +1076,7 @@ def initialize(name) @generalization = nil @system_members = nil @indexes = nil + @oql_view = nil end ATTR_TYPES.each do |type| @@ -1081,6 +1088,16 @@ def initialize(name) def non_persistent! = (@persistable = false) def documentation(d) = (@doc = d) + # Marks an entity as an OQL-backed view. Modern Mendix projects keep the + # query in a separate ViewEntitySourceDocument; older projects may keep + # it directly on the embedded entity. + def oql_view(source: nil, query: nil) + raise ArgumentError, 'oql_view requires source or query' if source.nil? && query.nil? + + @oql_view = { source: source&.to_s, query: query&.to_s }.compact + @persistable = false + end + def generalizes(entity) @generalization = entity.to_s end @@ -1164,7 +1181,7 @@ def to_h name: @name, persistable: @persistable, documentation: @doc, attributes: @attributes, associations: @associations, lifecycle: @lifecycle, access_rules: @access_rules, generalization: @generalization, - system_members: @system_members, indexes: @indexes + system_members: @system_members, indexes: @indexes, oql_view: @oql_view } end diff --git a/lib/mxrb/exporter.rb b/lib/mxrb/exporter.rb index 0c989c8..cc0ff03 100644 --- a/lib/mxrb/exporter.rb +++ b/lib/mxrb/exporter.rb @@ -9,6 +9,7 @@ module Mxrb # Exports an MPR into an editable, layered Ruby source tree. class Exporter LAYERS = %w[domain application presentation infrastructure].freeze + MODULE_PROGRESS_WEIGHT = 10 MARKETPLACE_PROVENANCE = [ ".mxrb/marketplace.lock.json", ".mxrb/marketplace", @@ -63,22 +64,30 @@ def initialize(mpr_path, output_dir) end def export!(parallel: true) - FileUtils.mkdir_p(@output_dir) - Mxrb.open(@mpr_path) do |project| - @architecture = project.architecture_definition - export_app_structure - export_project_assets - export_native_units(project) - export_security(project) - export_architecture_contracts(project) - mods = project.modules - if parallel && mods.size > 1 - threads = mods.map { |mod| Thread.new { export_module(mod) } } - threads.each(&:join) - else - mods.each { export_module(_1) } + Progress.with("Exporting #{File.basename(@mpr_path)}") do |progress| + FileUtils.mkdir_p(@output_dir) + Mxrb.open(@mpr_path) do |project| + @architecture = project.architecture_definition + units = project.all_units + modules = project.modules + assets = project_asset_files + progress.update( + current: 0, + total: 4 + assets.size + (units.size * 2) + (modules.size * MODULE_PROGRESS_WEIGHT), + detail: "preparing Ruby project" + ) + export_app_structure + progress.advance(detail: "project structure") + export_project_assets(assets, progress) + export_native_units(project, units, progress) + export_security(project) + progress.advance(detail: "project security") + export_architecture_contracts(project) + progress.advance(detail: "navigation and design system") + export_modules(modules, progress, parallel:) + write_project(project) + progress.advance(detail: "project.rb") end - write_project(project) end @output_dir end @@ -91,6 +100,8 @@ def export_module(mod) export_domain(root, mod) export_microflows(root, mod) + export_application_documents(root, mod) + export_infrastructure_documents(root, mod) export_pages(root, mod) export_menus(root, mod) export_nanoflows(root, mod) @@ -106,25 +117,31 @@ def export_app_structure ].each { write(File.join(@output_dir, _1, ".keep"), "") } end - def export_project_assets + def project_asset_files source_root = File.dirname(@mpr_path) - files = (Model::DesignSystem::ASSET_DIRECTORIES + MARKETPLACE_PROVENANCE).flat_map do |directory| + (Model::DesignSystem::ASSET_DIRECTORIES + MARKETPLACE_PROVENANCE).flat_map do |directory| root = File.join(source_root, directory) next [root] if File.file?(root) && !File.symlink?(root) next [] unless File.directory?(root) && !File.symlink?(root) Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH) end.select { File.file?(_1) && !File.symlink?(_1) }.sort + end + + def export_project_assets(files, progress) + source_root = File.dirname(@mpr_path) entries = files.map do |source| relative = Pathname.new(source).relative_path_from(Pathname.new(source_root)).to_s target = File.join(@output_dir, relative) FileUtils.mkdir_p(File.dirname(target)) FileUtils.cp(source, target) - { + result = { "path" => relative, "size" => File.size(source), "sha256" => Digest::SHA256.file(source).hexdigest } + progress.advance(detail: "asset #{relative}") + result end write( File.join(@output_dir, ".mxrb", "assets.json"), @@ -132,8 +149,8 @@ def export_project_assets ) end - def export_native_units(project) - units = project.all_units.filter_map do |unit| + def export_native_units(project, project_units, progress) + units = project_units.filter_map do |unit| doc = project.parse_bson(unit) type = doc["$Type"] next if type.to_s.empty? || type == "Projects$Project" @@ -147,16 +164,21 @@ def export_native_units(project) "type" => type, "contents" => Base64.strict_encode64(IO::BsonCodec.serialize(doc)) } + ensure + progress.advance(detail: "native baseline") end write( File.join(@output_dir, ".mxrb", "native_units.json"), JSON.pretty_generate("format_version" => project.format_version.to_s, "units" => units) ) - source = project.all_units.filter_map do |unit| + source = project_units.filter_map do |unit| doc = project.parse_bson(unit) next if doc["$Type"].to_s.empty? || doc["$Type"] == "Projects$Project" + next if Model::Module::EDITABLE_DOCUMENT_TYPES.include?(doc["$Type"]) native_unit_source(project, unit, doc) + ensure + progress.advance(detail: "editable native units") end write( File.join(@output_dir, ".mxrb", "native_units.rb"), @@ -164,6 +186,18 @@ def export_native_units(project) ) end + def export_modules(modules, progress, parallel:) + operation = lambda do |mod| + export_module(mod) + progress.advance(MODULE_PROGRESS_WEIGHT, detail: "module #{mod.name}") + end + if parallel && modules.size > 1 + modules.map { |mod| Thread.new { operation.call(mod) } }.each(&:join) + else + modules.each { operation.call(_1) } + end + end + def native_unit_source(project, unit, doc) <<~RUBY native_unit #{ruby(unit.fetch("UnitID"))}, @@ -211,8 +245,10 @@ def export_architecture_contracts(project) def export_module_scaffolding(root) %w[ - domain/enumerations domain/rules domain/policies - application/ports/repositories application/queries application/validations application/jobs + domain/entities domain/dtos domain/oql_views domain/enumerations domain/constants + domain/rules domain/policies + application/ports/repositories application/queries application/queries/datasets + application/validations application/jobs application/jobs/scheduled_events presentation/features presentation/client_actions presentation/snippets presentation/view_models presentation/menus infrastructure/persistence/mendix infrastructure/persistence/external @@ -238,26 +274,62 @@ def export_module_security(root, mod) def export_domain(root, mod) domain = File.join(root, "domain") - entities_dir = File.join(domain, "entities") - FileUtils.mkdir_p(entities_dir) - associations = mod.associations.group_by(&:from_entity_id) architecture = architecture_module(mod.name) - entity_files = unique_entity_filenames(mod.entities) + paths = {} + grouped = mod.entities.group_by { entity_domain_route(_1) } + grouped.each do |route, entities| + unique_entity_filenames(entities).each do |id, filename| + paths[id] = File.join(route, filename) + end + end + domain_documents = mod.domain_documents + oql_documents = mod.oql_view_documents + attached_oql_documents = {} mod.entities.each do |entity| entity_metadata = architecture&.fetch(:entities, [])&.find { _1[:name] == entity.name } + source = entity_source(entity, mod, associations.fetch(entity.id, []), entity_metadata) + if oql_view_entity?(entity) && (document = oql_document_for(entity, oql_documents, mod.name)) + source = "#{source.rstrip}\n\n#{native_document_declaration(document)}" + attached_oql_documents[document.fetch(:id)] = true + end write( - File.join(entities_dir, entity_files.fetch(entity.id)), - entity_source(entity, mod, associations.fetch(entity.id, []), entity_metadata) + File.join(domain, paths.fetch(entity.id)), source ) end - - loads = entity_files.values.sort.map do |filename| - %(evaluate File.join(__dir__, "entities", #{ruby(filename)})) + used = paths.values.to_h { [_1, true] } + domain_documents.reject { attached_oql_documents[_1.fetch(:id)] }.each do |document| + relative = unique_relative_path(document.fetch(:route), underscore(document.fetch(:name)), used) + write(File.join(domain, relative), mapping_document_source(document)) + paths[document.fetch(:id)] = relative + end + loads = paths.values.sort.map do |relative| + segments = relative.split(File::SEPARATOR).map { ruby(_1) }.join(", ") + %(evaluate File.join(__dir__, #{segments})) end write(File.join(domain, "model.rb"), "#{loads.join("\n")}\n") end + def entity_domain_route(entity) + return 'oql_views' if oql_view_entity?(entity) + return 'dtos' unless entity.persistable + + 'entities' + end + + def oql_view_entity?(entity) + entity.respond_to?(:oql_view?) && entity.oql_view? + end + + def oql_document_for(entity, documents, module_name) + reference = entity.oql_source_document.to_s + return nil if reference.empty? + + documents.find do |document| + [document.fetch(:name), "#{module_name}.#{document.fetch(:name)}"].include?(reference) + end + end + def export_microflows(root, mod) files = unique_filenames(mod.microflows) by_layer = { "application" => [], "infrastructure" => [] } @@ -296,6 +368,74 @@ def export_pages(root, mod) write_path_aggregator(File.join(presentation, "presentation.rb"), paths) end + def export_infrastructure_documents(root, mod) + documents = mod.infrastructure_documents + return if documents.empty? + + infrastructure = File.join(root, "infrastructure") + used = {} + paths = documents.map do |document| + base = underscore(document.fetch(:name)) + relative = unique_relative_path(document.fetch(:route), base, used) + write(File.join(infrastructure, relative), mapping_document_source(document)) + relative + end + append_to_aggregator(File.join(infrastructure, "infrastructure.rb"), paths) + end + + def export_application_documents(root, mod) + documents = mod.application_documents + return if documents.empty? + + application = File.join(root, 'application') + used = {} + paths = documents.map do |document| + relative = unique_relative_path(document.fetch(:route), underscore(document.fetch(:name)), used) + write(File.join(application, relative), mapping_document_source(document)) + relative + end + append_to_aggregator(File.join(application, 'application.rb'), paths) + end + + def unique_relative_path(directory, base, used) + candidate = File.join(directory, "#{base}.rb") + suffix = 2 + while used[candidate] + candidate = File.join(directory, "#{base}_#{suffix}.rb") + suffix += 1 + end + used[candidate] = true + candidate + end + + def mapping_document_source(document) + <<~RUBY + # frozen_string_literal: true + + #{native_document_declaration(document)} + RUBY + end + + def native_document_declaration(document) + <<~RUBY.rstrip + native_document #{symbol(document.fetch(:name))}, + type: #{ruby(document.fetch(:type))}, + unit_id: #{ruby(document.fetch(:id))}, + container_id: #{ruby(document.fetch(:container_id))}, + containment: #{ruby(document.fetch(:containment))}, + deep_structure: #{native_ruby(document.fetch(:doc), 24)} + RUBY + end + + def append_to_aggregator(path, relative_paths) + existing = File.exist?(path) ? File.read(path).lines : [] + additions = relative_paths.sort.map do |relative| + segments = relative.split(File::SEPARATOR).map { ruby(_1) }.join(", ") + "evaluate File.join(__dir__, #{segments})\n" + end + write(path, (existing + additions).uniq.join) + end + def export_menus(root, mod) menus = mod.menus return if menus.empty? @@ -586,6 +726,12 @@ def entity_source(entity, mod, associations, metadata = nil) end flags = [] flags << " non_persistent!" unless entity.persistable + if oql_view_entity?(entity) + options = [] + options << "source: #{ruby(entity.oql_source_document)}" if entity.oql_source_document + options << "query: #{ruby(entity.oql_query)}" unless entity.oql_query.to_s.empty? + flags << " oql_view #{options.join(', ')}" unless options.empty? + end flags << " documentation #{ruby(entity.documentation)}" unless entity.documentation.to_s.empty? generalization = entity.respond_to?(:generalization_target) ? entity.generalization_target : nil flags << " generalizes #{ruby(generalization)}" if generalization diff --git a/lib/mxrb/integrity/validator.rb b/lib/mxrb/integrity/validator.rb index 8b10ac8..5c69743 100644 --- a/lib/mxrb/integrity/validator.rb +++ b/lib/mxrb/integrity/validator.rb @@ -11,19 +11,26 @@ def valid? = errors.empty? class Validator def initialize(path) @path = path + @progress = Progress::NullTask.instance end def validate - @errors = [] - @warnings = [] - @mpr = IO::MprFile.open(@path, readonly: true) - validate_tables - validate_units - validate_v2_files - Result.new(errors: @errors, warnings: @warnings) + Progress.with("Validating #{File.basename(@path)}") do |progress| + @progress = progress + @errors = [] + @warnings = [] + @mpr = IO::MprFile.open(@path, readonly: true) + validate_tables + progress.advance(detail: "MPR tables") + validate_units + validate_v2_files + progress.advance(detail: "v2 contents") + Result.new(errors: @errors, warnings: @warnings) + end rescue Error => e Result.new(errors: [e.message], warnings: @warnings || []) ensure + @progress = Progress::NullTask.instance @mpr&.close end @@ -40,8 +47,10 @@ def validate_tables def validate_units @units = @mpr.all_units + @progress.update(total: @units.size + 3, detail: "#{@units.size} units") validate_root validate_unit_ids + @progress.advance(detail: "unit tree") validate_contents end @@ -78,6 +87,8 @@ def validate_contents next unless doc validate_doc_identity(unit, doc) + ensure + @progress&.advance(detail: "unit #{unit['UnitID']}") end end diff --git a/lib/mxrb/model/entity.rb b/lib/mxrb/model/entity.rb index 4ee59bb..2f46899 100644 --- a/lib/mxrb/model/entity.rb +++ b/lib/mxrb/model/entity.rb @@ -10,7 +10,7 @@ class Entity # rubocop:disable Metrics/ClassLength attr_accessor :id, :name, :qualified_name, :documentation, :persistable, :location, :data_storage_guid, :export_level, :generalization, :access_rules, :indexes, - :system_members + :system_members, :source, :oql_query, :native_type # Build from a BSON hash (embedded in DomainModel's "entities" array). def self.from_bson(doc, _domain_model_id, mpr) @@ -19,6 +19,9 @@ def self.from_bson(doc, _domain_model_id, mpr) e.name = doc["name"] || doc["Name"] e.qualified_name = doc["$QualifiedName"] || doc["\$QualifiedName"] e.documentation = doc["documentation"] || doc["Documentation"] || "" + e.native_type = doc["$Type"] + e.source = doc["source"] || doc["Source"] + e.oql_query = doc["oqlQuery"] || doc["OqlQuery"] || doc["OQLQuery"] e.data_storage_guid = doc["dataStorageGuid"] || doc["DataStorageGuid"] e.export_level = doc["exportLevel"] || doc["ExportLevel"] || "Hidden" e.location = parse_location(doc["location"] || doc["Location"]) @@ -58,6 +61,19 @@ def generalization_target @generalization['generalization'] || @generalization['Generalization'] end + def oql_view? + source_type = @source.is_a?(Hash) ? @source.fetch('$Type', '') : '' + @native_type.to_s.match?(/ViewEntity/i) || + source_type.match?(/OqlViewEntitySource/i) || + !@oql_query.to_s.empty? + end + + def oql_source_document + return unless @source.is_a?(Hash) + + @source['SourceDocument'] || @source['sourceDocument'] + end + def to_bson { "$ID" => @id, diff --git a/lib/mxrb/model/module.rb b/lib/mxrb/model/module.rb index f523bb9..5a23dfc 100644 --- a/lib/mxrb/model/module.rb +++ b/lib/mxrb/model/module.rb @@ -7,6 +7,40 @@ module Model # $Type: Projects$Module # ContainmentName: "Modules" class Module < Unit + INFRASTRUCTURE_DOCUMENT_ROUTES = { + 'ExportMappings$ExportMapping' => 'mappings/exports', + 'ImportMappings$ImportMapping' => 'mappings/imports', + 'JsonStructures$JsonStructure' => 'mappings/json_structures', + 'MessageDefinitions$MessageDefinitionCollection' => 'mappings/message_definitions', + 'MessageDefinitions$MessageDefinition2' => 'mappings/message_definitions', + 'XmlSchemas$XmlSchema' => 'mappings/xml_schemas', + 'Rest$PublishedRestService' => 'endpoints', + 'WebServices$PublishedService' => 'endpoints', + 'WebServices$PublishedWebService' => 'endpoints', + 'ODataPublish$PublishedODataService' => 'endpoints', + 'ODataPublish$PublishedODataService2' => 'endpoints', + 'Rest$ConsumedRestService' => 'integrations', + 'Rest$ConsumedODataService' => 'integrations', + 'AppServices$ConsumedAppService' => 'integrations', + 'ODataImport$ConsumedODataService' => 'integrations' + }.freeze + MAPPING_DOCUMENT_TYPES = INFRASTRUCTURE_DOCUMENT_ROUTES.keys.grep( + /Mappings|JsonStructures|MessageDefinitions|XmlSchemas/ + ).freeze + APPLICATION_DOCUMENT_ROUTES = { + 'DataSets$DataSet' => 'queries/datasets', + 'ScheduledEvents$ScheduledEvent' => 'jobs/scheduled_events' + }.freeze + DOMAIN_DOCUMENT_ROUTES = { + 'DomainModels$ViewEntitySourceDocument' => 'oql_views', + 'Enumerations$Enumeration' => 'enumerations', + 'Constants$Constant' => 'constants' + }.freeze + EDITABLE_DOCUMENT_TYPES = ( + INFRASTRUCTURE_DOCUMENT_ROUTES.keys + APPLICATION_DOCUMENT_ROUTES.keys + + DOMAIN_DOCUMENT_ROUTES.keys + ).freeze + attr_reader :name, :sort_index, :from_app_store, :app_store_guid, :app_store_version, :export_level @@ -84,6 +118,28 @@ def scheduled_events .map { unit_to_doc(_1) } end + def mapping_documents + infrastructure_documents.select { MAPPING_DOCUMENT_TYPES.include?(_1[:type]) } + end + + def infrastructure_documents + @infrastructure_documents ||= routed_documents(INFRASTRUCTURE_DOCUMENT_ROUTES) + end + + def application_documents + @application_documents ||= routed_documents(APPLICATION_DOCUMENT_ROUTES) + end + + def domain_documents + @domain_documents ||= routed_documents(DOMAIN_DOCUMENT_ROUTES) + end + + def oql_view_documents + @oql_view_documents ||= domain_documents.select do |document| + document[:type] == 'DomainModels$ViewEntitySourceDocument' + end + end + def module_roles @module_roles ||= begin raw = @mpr.children_of(@id).find { _1["ContainmentName"] == "ModuleSecurity" } @@ -109,6 +165,21 @@ def unit_to_doc(unit_hash) @mpr.parse_contents(unit_hash[:raw]) end + def routed_documents(routes) + document_units.filter_map do |unit| + route = routes[unit[:type]] + next unless route + + raw = unit.fetch(:raw) + doc = @mpr.parse_contents(raw) + { + id: raw.fetch("UnitID"), container_id: raw.fetch("ContainerID"), + containment: raw.fetch("ContainmentName"), type: unit.fetch(:type), + name: doc["Name"] || doc["name"] || raw.fetch("UnitID"), doc:, route: + } + end + end + # Documents live in ContainmentName = "Documents" recursively under this module. # We do a simple two-pass: direct Documents children + Documents inside Folders. def document_units diff --git a/lib/mxrb/official_marketplace.rb b/lib/mxrb/official_marketplace.rb index a909d7c..3a551a2 100644 --- a/lib/mxrb/official_marketplace.rb +++ b/lib/mxrb/official_marketplace.rb @@ -223,14 +223,20 @@ def initialize(github_token: ENV['GITHUB_TOKEN']) end def json(url, authorization: default_authorization) - JSON.parse(get(url, accept: 'application/json', authorization:)) + Progress.with('Loading Marketplace data') do |progress| + progress.update(detail: URI.parse(url).host) + JSON.parse(get(url, accept: 'application/json', authorization:)) + end rescue JSON::ParserError => e raise MarketplaceError, "invalid JSON response: #{e.message}" end def download(url, destination, authorization: default_authorization) - File.binwrite(destination, get(url, accept: 'application/octet-stream', authorization:)) - destination + Progress.with("Downloading #{File.basename(destination)}") do |progress| + progress.update(detail: URI.parse(url).host) + File.binwrite(destination, get(url, accept: 'application/octet-stream', authorization:)) + destination + end end private diff --git a/lib/mxrb/official_marketplace/module_package_importer.rb b/lib/mxrb/official_marketplace/module_package_importer.rb index 325e057..ff8f3b2 100644 --- a/lib/mxrb/official_marketplace/module_package_importer.rb +++ b/lib/mxrb/official_marketplace/module_package_importer.rb @@ -122,6 +122,7 @@ def initialize(target_root, temporary) def install(staged_files, protected_files = []) staged_files.each do |relative, source| install_file(relative, source) unless protected_files.include?(relative) + yield(relative) if block_given? end end @@ -179,9 +180,12 @@ def initialize(package_path, mpr_path, target_root: nil, allow_model_upgrade: fa end def import! - validate_inputs! - Dir.mktmpdir('mxrb-module-import-') do |temporary| - Zip::File.open(@package_path) { import_archive(_1, temporary) } + Progress.with("Importing #{File.basename(@package_path)}") do |progress| + validate_inputs! + progress.update(detail: 'reading package') + Dir.mktmpdir('mxrb-module-import-') do |temporary| + Zip::File.open(@package_path) { import_archive(_1, temporary, progress) } + end end rescue Zip::Error, REXML::ParseException => e raise MarketplaceError, "invalid Mendix module package: #{e.message}" @@ -189,12 +193,12 @@ def import! private - def import_archive(archive, temporary) + def import_archive(archive, temporary, progress) reader = ModulePackageReader.new(archive) descriptor = reader.descriptor source_path = reader.extract_project(descriptor, temporary) staged_files = reader.stage_files(descriptor.files, temporary) - import_project(source_path, descriptor, staged_files, temporary) + import_project(source_path, descriptor, staged_files, temporary, progress) end def validate_inputs! @@ -203,12 +207,13 @@ def validate_inputs! raise MarketplaceError, "target root not found: #{@target_root}" unless File.directory?(@target_root) end - def import_project(source_path, descriptor, staged_files, temporary) # rubocop:disable Metrics/MethodLength + def import_project(source_path, descriptor, staged_files, temporary, progress) # rubocop:disable Metrics/MethodLength source = IO::MprFile.open(source_path, readonly: true) target = IO::MprFile.open(@mpr_path) assets = ModulePackageAssets.new(@target_root, temporary) imported_ids = [] - import_transaction(source, target, descriptor, staged_files, assets, imported_ids) + import_transaction(source, target, descriptor, staged_files, assets, imported_ids, progress) + progress.advance(detail: 'import transaction') import_result(source, target, descriptor, staged_files, imported_ids) rescue StandardError assets&.rollback @@ -220,24 +225,30 @@ def import_project(source_path, descriptor, staged_files, temporary) # rubocop:d end # rubocop:disable Metrics/ParameterLists - def import_transaction(source, target, descriptor, staged_files, assets, imported_ids) + def import_transaction(source, target, descriptor, staged_files, assets, imported_ids, progress) # rubocop:disable Metrics/MethodLength validate_versions!(source, target, descriptor) module_unit, units = package_units(source, descriptor.name) validate_target!(target, descriptor.name, units) + progress.update( + current: 2, total: units.size + staged_files.size + 3, + detail: "#{units.size} model units" + ) target.transaction do - imported_ids.concat(insert_units(source, target, module_unit, units)) - assets.install(staged_files, @protected_files) + imported_ids.concat(insert_units(source, target, module_unit, units, progress)) + assets.install(staged_files, @protected_files) do |relative| + progress.advance(detail: "asset #{relative}") + end end end # rubocop:enable Metrics/ParameterLists - def insert_units(source, target, module_unit, units) + def insert_units(source, target, module_unit, units, progress) units.map do |unit| container = unit == module_unit ? target.root_unit.fetch('UnitID') : unit.fetch('ContainerID') target.insert_unit( container_uuid: container, containment_name: unit.fetch('ContainmentName'), contents_doc: source.parse_contents(unit) - ) + ).tap { progress.advance(detail: "unit #{unit.fetch('UnitID')}") } end end diff --git a/lib/mxrb/official_marketplace/widget_package_installer.rb b/lib/mxrb/official_marketplace/widget_package_installer.rb index b1ff61e..9b47569 100644 --- a/lib/mxrb/official_marketplace/widget_package_installer.rb +++ b/lib/mxrb/official_marketplace/widget_package_installer.rb @@ -200,17 +200,21 @@ def initialize(target:) end def install(archive, package) - validate_target! - inventory = WidgetPackageInventory.read(archive) - validate_identity!(inventory, package) - digest = Digest::SHA256.file(archive).hexdigest - destination = safe_path(File.join('widgets', inventory.project_filename)) - cache = safe_path(cache_relative(inventory, package)) - lock_path = safe_path(File.join('.mxrb', 'marketplace.lock.json')) - current = validate_boundaries!(package, destination, cache, lock_path) - install_transaction( - archive, package, inventory, digest, destination, cache, lock_path, current - ) + Progress.with("Installing widget #{package.name}") do |progress| + validate_target! + progress.update(detail: 'reading widget package') + inventory = WidgetPackageInventory.read(archive) + validate_identity!(inventory, package) + digest = Digest::SHA256.file(archive).hexdigest + destination = safe_path(File.join('widgets', inventory.project_filename)) + cache = safe_path(cache_relative(inventory, package)) + lock_path = safe_path(File.join('.mxrb', 'marketplace.lock.json')) + current = validate_boundaries!(package, destination, cache, lock_path) + progress.update(detail: 'installing project assets') + install_transaction( + archive, package, inventory, digest, destination, cache, lock_path, current + ) + end end private diff --git a/lib/mxrb/oql.rb b/lib/mxrb/oql.rb index 1faaac0..c8a441d 100644 --- a/lib/mxrb/oql.rb +++ b/lib/mxrb/oql.rb @@ -49,7 +49,7 @@ def queries # Recursive BSON discovery is intentionally kept together so that its # source-type guard and ownership context cannot diverge. - # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength def discover(document, raw, module_name) found = [] walk(document) do |node, path, ancestors| @@ -59,6 +59,11 @@ def discover(document, raw, module_name) node['Query'], raw, module_name, path + ['Query'], source_type, ancestors ) end + if source_type == 'DomainModels$ViewEntitySourceDocument' && node['Oql'].is_a?(String) + found << build_query( + node['Oql'], raw, module_name, path + ['Oql'], source_type, ancestors + ) + end node.each do |key, value| next unless key.to_s.match?(/\AOqlQuery\z/i) && value.is_a?(String) @@ -67,7 +72,7 @@ def discover(document, raw, module_name) end found.uniq { [_1.unit_id, _1.path] } end - # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength def walk(node, path = [], ancestors = [], &block) @@ -97,7 +102,8 @@ def build_query(oql, raw, module_name, path, source_type, ancestors) unit_type = @project.parse_bson(raw)['$Type'].to_s kind = if unit_type == 'DataSets$DataSet' :dataset - elsif owner['$Type'].to_s.match?(/Entity/i) + elsif unit_type == 'DomainModels$ViewEntitySourceDocument' || + owner['$Type'].to_s.match?(/Entity/i) :view_entity else :oql diff --git a/lib/mxrb/progress.rb b/lib/mxrb/progress.rb new file mode 100644 index 0000000..065ae62 --- /dev/null +++ b/lib/mxrb/progress.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +require 'io/console' +require 'singleton' + +module Mxrb + # Terminal progress rendering shared by every long-running MXRB operation. + # It is enabled automatically only for an interactive terminal, writes to + # stderr, and therefore never contaminates command output or JSON on stdout. + module Progress + THREAD_KEY = :mxrb_progress_task + FALSE_VALUES = %w[0 false no off].freeze + + # No-op object returned when progress rendering is disabled. + class NullTask + include Singleton + + def start = self + def update(**) = self + def advance(*, **) = self + def add_total(*) = self + def finish(*) = self + def fail(*) = self + def enabled? = false + end + + # Thread-safe terminal renderer for a single operation. + class Task # rubocop:disable Metrics/ClassLength + SPINNER = %w[| / - \\].freeze + BAR_WIDTH = 28 + REFRESH_INTERVAL = 0.08 + + attr_reader :label, :total, :current + + def initialize(label, total: nil, io: $stderr) + @label = label.to_s + @total = normalize_total(total) + @io = io + @current = 0 + @detail = nil + @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @last_rendered_at = 0.0 + @spinner_index = 0 + @mutex = Mutex.new + @finished = false + end + + def enabled? = true + + def start + render(force: true) + start_spinner unless determinate? + self + end + + def update(current: nil, total: nil, detail: nil, force: false) + became_determinate = false + @mutex.synchronize do + became_determinate = @total.nil? && !total.nil? + @total = normalize_total(total) unless total.nil? + @current = [[Integer(current), 0].max, @total || Float::INFINITY].min unless current.nil? + @detail = detail.to_s unless detail.nil? + end + stop_spinner if became_determinate + render(force:) + self + end + + def advance(amount = 1, detail: nil, force: false) + @mutex.synchronize do + @current += amount + @current = [@current, @total].min if @total + @detail = detail.to_s unless detail.nil? + end + render(force:) + self + end + + def add_total(amount) + @mutex.synchronize { @total = (@total || 0) + Integer(amount) } + render(force: true) + self + end + + def finish(detail = nil) + stop_spinner + @mutex.synchronize do + return self if @finished + + @detail = detail.to_s if detail + @current = @total if @total + @finished = true + end + render(force: true, final: true) + self + end + + def fail(message = nil) + stop_spinner + @mutex.synchronize do + return self if @finished + + @detail = message.to_s unless message.to_s.empty? + @finished = true + end + render(force: true, final: true, failed: true) + self + end + + private + + def normalize_total(value) + return nil if value.nil? + + [Integer(value), 1].max + end + + def determinate? = !@total.nil? + + def start_spinner + return unless dynamic_terminal? + + @spinner_thread = Thread.new do + loop do + sleep REFRESH_INTERVAL + break if @mutex.synchronize { @finished } + + render(force: true) + end + end + end + + def stop_spinner + thread = @spinner_thread + return unless thread + + @mutex.synchronize { @finished = true } + thread.join + @spinner_thread = nil + @mutex.synchronize { @finished = false } + end + + def render(force: false, final: false, failed: false) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + line = nil + @mutex.synchronize do + now = Process.clock_gettime(Process::CLOCK_MONOTONIC) + return if !force && !final && now - @last_rendered_at < REFRESH_INTERVAL + + @last_rendered_at = now + line = rendered_line(now, failed:) + end + prefix = dynamic_terminal? ? "\r\e[2K" : '' + suffix = final || !dynamic_terminal? ? "\n" : '' + @io.write("#{prefix}#{truncate(line)}#{suffix}") + @io.flush if @io.respond_to?(:flush) + rescue IOError, Errno::EPIPE + nil + end + + def rendered_line(now, failed:) + detail = @detail.to_s.empty? ? '' : " - #{@detail}" + elapsed = format('%.1fs', now - @started_at) + return "[mxrb] [FAILED] #{@label}#{detail} (#{elapsed})" if failed + + determinate_line(detail, elapsed) || spinner_line(detail, elapsed) + end + + def determinate_line(detail, elapsed) + return unless determinate? + + ratio = [@current.fdiv(@total), 1.0].min + filled = (ratio * BAR_WIDTH).round + bar = '#' * filled + '-' * (BAR_WIDTH - filled) + "[mxrb] [#{bar}] #{format('%3d%%', ratio * 100)} #{@label}#{detail} (#{elapsed})" + end + + def spinner_line(detail, elapsed) + frame = SPINNER[@spinner_index % SPINNER.length] + @spinner_index += 1 + "[mxrb] [#{frame}] #{@label}#{detail} (#{elapsed})" + end + + def dynamic_terminal? + @io.respond_to?(:tty?) && @io.tty? + end + + def truncate(line) + width = @io.respond_to?(:winsize) ? @io.winsize.last : 100 + width = 100 unless width.to_i.positive? + return line if line.length <= width + + "#{line[0, width - 3]}..." + rescue IOError, Errno::ENOTTY + line + end + end # rubocop:enable Metrics/ClassLength + + class << self + attr_writer :io + + def configure(enabled: nil, io: nil) + @enabled = enabled unless enabled.nil? + @io = io if io + end + + def reset! + @enabled = nil + @io = nil + end + + def enabled? + return @enabled unless @enabled.nil? + return false if FALSE_VALUES.include?(ENV.fetch('MXRB_PROGRESS', '').downcase) + + output.respond_to?(:tty?) && output.tty? + end + + def with(label, total: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + active = Thread.current[THREAD_KEY] + return yield(active) if active + return yield(NullTask.instance) unless enabled? + + task = Task.new(label, total:, io: output).start + Thread.current[THREAD_KEY] = task + result = yield(task) + task.finish + result + rescue StandardError => e + task&.fail(e.message) + raise + ensure + Thread.current[THREAD_KEY] = nil if defined?(task) && task + end + + def current = Thread.current[THREAD_KEY] || NullTask.instance + + private + + def output = @io || $stderr + end + end +end diff --git a/lib/mxrb/team_server.rb b/lib/mxrb/team_server.rb index 7524313..15b4fac 100644 --- a/lib/mxrb/team_server.rb +++ b/lib/mxrb/team_server.rb @@ -161,7 +161,7 @@ def authenticated_environment(helper, token) # Git transport for Team Server. PATs are passed to a short-lived # GIT_ASKPASS process and are never embedded in a URL or git config. - class Repository + class Repository # rubocop:disable Metrics/ClassLength def initialize(credentials: Credentials.new, runner: CommandRunner.new, authenticator: GitAuthenticator.new(credentials)) @runner = runner @@ -249,11 +249,14 @@ def positive_depth(value) end def capture!(command, chdir: nil) - @authenticator.call do |environment| - output, status = @runner.capture(environment, command, chdir:) - raise TeamServerError, "Team Server Git operation failed: #{output.strip}" unless status.success? - - output + Progress.with("Team Server #{command.first(2).join(' ')}") do |progress| + progress.update(detail: chdir || 'remote repository') + @authenticator.call do |environment| + output, status = @runner.capture(environment, command, chdir:) + raise TeamServerError, "Team Server Git operation failed: #{output.strip}" unless status.success? + + output + end end end @@ -285,7 +288,7 @@ def validate_mprs!(root) mpr end end - end + end # rubocop:enable Metrics/ClassLength # Read-only client for Mendix's official App Repository API. class Api diff --git a/lib/mxrb/version.rb b/lib/mxrb/version.rb index c9f3b72..2320f45 100644 --- a/lib/mxrb/version.rb +++ b/lib/mxrb/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Mxrb - VERSION = "0.1.2" + VERSION = "0.1.3" end diff --git a/lib/mxrb/writer.rb b/lib/mxrb/writer.rb index abd6b0c..b60e01f 100644 --- a/lib/mxrb/writer.rb +++ b/lib/mxrb/writer.rb @@ -31,19 +31,32 @@ class Writer def initialize(path, definition) @path = File.expand_path(path) @definition = definition + @progress = Progress::NullTask.instance end def write! - create_project! unless File.exist?(@path) - mpr = IO::MprFile.open(@path) - mpr.transaction do - apply(mpr) - mpr.write_architecture_definition(@definition) - end - materialize_project_assets - materialize_design_system + mpr = nil + native_units = prepared_native_units + asset_manifest = project_asset_manifest + total = 4 + native_units.count { _1["containment"] != "Modules" } + + @definition.fetch(:modules).size + Array(asset_manifest&.fetch("files", [])).size + Progress.with("Generating #{File.basename(@path)}", total:) do |progress| + @progress = progress + create_project! unless File.exist?(@path) + progress.advance(detail: "project container") + mpr = IO::MprFile.open(@path) + mpr.transaction do + apply(mpr, native_units) + mpr.write_architecture_definition(@definition) + end + progress.advance(detail: "model transaction") + materialize_project_assets(asset_manifest) + materialize_design_system + progress.advance(detail: "design system") + end self ensure + @progress = Progress::NullTask.instance mpr&.close end @@ -56,11 +69,17 @@ def materialize_design_system Model::DesignMaterializer.new(File.dirname(@path), design_system).materialize! end - def materialize_project_assets + def project_asset_manifest assets = @definition[:project_assets] return unless assets - manifest = JSON.parse(File.read(assets.fetch(:manifest))) + JSON.parse(File.read(assets.fetch(:manifest))) + end + + def materialize_project_assets(manifest) + assets = @definition[:project_assets] + return unless assets && manifest + source_root = File.realpath(assets.fetch(:root)) target_root = File.dirname(@path) manifest.fetch("files").each do |entry| @@ -79,6 +98,7 @@ def materialize_project_assets FileUtils.mv(temporary, target) ensure FileUtils.rm_f(temporary) if temporary + @progress.advance(detail: "asset #{relative}") if defined?(relative) && relative end end @@ -161,7 +181,7 @@ def native_format_version JSON.parse(File.read(path))["format_version"] end - def apply(mpr) + def apply(mpr, native_units) root = mpr.root_unit root_id = root.fetch("UnitID") root_doc = mpr.parse_contents(root) @@ -171,10 +191,6 @@ def apply(mpr) "IsSystemProject" => false } mpr.update_unit(root_id, root_doc) - native_units = load_native_units(@definition[:native_units_path]) - native_units = apply_native_unit_overrides( - native_units, @definition.fetch(:native_unit_overrides, []) - ) apply_native_project_units(mpr, root_id, native_units) apply_default_project_units(mpr, root_id) ensure_project_documents(mpr, root_id) @@ -194,9 +210,18 @@ def apply(mpr) write_module_security(mpr, module_id, mod) if mod.key?(:module_roles) write_domain_model(mpr, module_id, mod) write_documents(mpr, module_id, mod) + @progress.advance(detail: "module #{mod.fetch(:name)}") end write_project_security(mpr, root_id, @definition[:security]) if @definition[:security] write_project_navigation(mpr, root_id, @definition[:navigation]) if @definition[:navigation] + @progress.advance(detail: "project security and navigation") + end + + def prepared_native_units + apply_native_unit_overrides( + load_native_units(@definition[:native_units_path]), + @definition.fetch(:native_unit_overrides, []) + ) end def load_native_units(path) @@ -323,8 +348,20 @@ def write_native_documents(mpr, module_id, mod) mod.fetch(:native_documents, []).each do |document| doc = document.fetch(:doc) doc = legacy_layout_doc(doc) if doc['$Type'] == 'Forms$Layout' && legacy_layout? + existing = mpr.unit(document[:unit_id]) if document[:unit_id] + if existing + current = mpr.parse_contents(existing) + preserved = current.merge(doc).merge( + '$ID' => current['$ID'] || existing.fetch('UnitID'), '$Type' => doc.fetch('$Type') + ) + mpr.update_unit(existing.fetch('UnitID'), preserved) + next + end + + requested_container = document[:container_id] + target_container = requested_container && mpr.unit(requested_container) ? requested_container : module_id upsert_native_unit( - mpr, module_id, + mpr, target_container, 'containment' => document.fetch(:containment), 'doc' => doc ) end @@ -365,7 +402,10 @@ def oldest_layout_contract? def apply_native_unit_tree(mpr, target_root_id, units) if units.any? { _1["unit_id"].to_s.empty? || _1["container_id"].to_s.empty? } - units.each { upsert_native_unit(mpr, target_root_id, _1) } + units.each do |unit| + upsert_native_unit(mpr, target_root_id, unit) + @progress.advance(detail: "native unit #{unit['name']}") + end return end @@ -382,6 +422,7 @@ def apply_native_unit_tree(mpr, target_root_id, units) target_container = mapped_containers.fetch(unit.fetch("container_id")) actual_id = upsert_native_unit(mpr, target_container, unit) mapped_containers[unit.fetch("unit_id")] = actual_id + @progress.advance(detail: "native unit #{unit['name']}") end pending = blocked end @@ -1360,7 +1401,7 @@ def entity_doc(entity, module_name, previous, index, access_associations: []) previous&.dig(rules_key) || IO::BsonCodec.build_array([]) end doc = (previous || {}).merge( - "$ID" => id, "$Type" => "DomainModels$EntityImpl", + "$ID" => id, "$Type" => previous&.fetch("$Type", nil) || "DomainModels$EntityImpl", "Name" => entity.fetch(:name), "Documentation" => entity.fetch(:documentation, ""), "GUID" => previous&.dig("GUID") || SecureRandom.uuid, "Location" => previous&.dig("Location") || "#{(index % 4) * 220};#{(index / 4) * 160}", @@ -1368,6 +1409,7 @@ def entity_doc(entity, module_name, previous, index, access_associations: []) "IsRemote" => previous&.fetch("IsRemote", false) || false, "RemoteSource" => previous&.fetch("RemoteSource", "") || "" ) + apply_oql_view!(doc, entity.fetch(:oql_view, nil), previous) doc[attrs_key] = IO::BsonCodec.build_array(attrs) doc[rules_key] = access_rules doc[validation_key] = validation_rules_doc( @@ -1397,6 +1439,25 @@ def entity_doc(entity, module_name, previous, index, access_associations: []) doc end + def apply_oql_view!(doc, view, previous) + return unless view + + if view[:source] + source_key = native_existing_key(previous, 'source', 'Source') || 'Source' + current_source = previous&.dig(source_key) + source = (current_source.is_a?(Hash) ? current_source : {}).merge( + '$ID' => current_source&.fetch('$ID', nil) || SecureRandom.uuid, + '$Type' => 'DomainModels$OqlViewEntitySource', + 'SourceDocument' => view.fetch(:source) + ) + doc[source_key] = source + end + return unless view[:query] + + query_key = native_existing_key(previous, 'oqlQuery', 'OqlQuery', 'OQLQuery') || 'OqlQuery' + doc[query_key] = view.fetch(:query) + end + def attribute_doc(attr, previous) storage_type = Model::Attribute::TYPE_MAP.fetch(attr.fetch(:type)) type_key = native_existing_key(previous, "type", "Type", "newType", "NewType") || "NewType" diff --git a/mxrb.gemspec b/mxrb.gemspec index c613cbf..4b5a345 100644 --- a/mxrb.gemspec +++ b/mxrb.gemspec @@ -2,7 +2,7 @@ Gem::Specification.new do |s| s.name = "mxrb" - s.version = "0.1.2" + s.version = "0.1.3" s.summary = "Pure-Ruby read/write engine for Mendix .mpr projects — no mxcli required" s.description = "mxrb reads and writes Mendix .mpr files (SQLite3) directly, providing a Ruby DSL to define entities, pages, microflows and modules without any dependency on the official mxcli tooling." s.authors = ["Lucas Moura"] diff --git a/spec/exported_domain_artifacts_spec.rb b/spec/exported_domain_artifacts_spec.rb new file mode 100644 index 0000000..f7e2ec2 --- /dev/null +++ b/spec/exported_domain_artifacts_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +# rubocop:disable Metrics/BlockLength +RSpec.describe 'exported domain artifacts' do + it 'separates persistent entities, DTOs, OQL views, and datasets without losing OQL' do + Dir.mktmpdir('mxrb-domain-artifacts-') do |dir| + source = File.join(dir, 'Domain.mpr') + exported = File.join(dir, 'ruby') + rebuilt = File.join(dir, 'rebuilt.mpr') + define_domain_project(source) + + Mxrb::Exporter.new(source, exported).export! + root = File.join(exported, 'modules', 'API_Rest') + expect(File).to exist(File.join(root, 'domain', 'entities', 'product.rb')) + expect(File).to exist(File.join(root, 'domain', 'dtos', 'product_dto.rb')) + view_path = File.join(root, 'domain', 'oql_views', 'product_view.rb') + expect(File.read(view_path)).to include( + 'oql_view source: "API_Rest.ProductViewSource"', + 'DomainModels$ViewEntitySourceDocument', + 'SELECT Name FROM API_Rest.Product' + ) + legacy_view_path = File.join(root, 'domain', 'oql_views', 'legacy_view.rb') + expect(File.read(legacy_view_path)).to include( + 'oql_view query: "SELECT Name FROM API_Rest.Product"' + ) + dataset_path = File.join(root, 'application', 'queries', 'datasets', 'product_data.rb') + expect(File.read(dataset_path)).to include('DataSets$DataSet', 'OqlDataSetSource') + enumeration_path = File.join(root, 'domain', 'enumerations', 'location_type.rb') + expect(File.read(enumeration_path)).to include('Enumerations$Enumeration', 'Warehouse') + constant_path = File.join(root, 'domain', 'constants', 'api_address.rb') + expect(File.read(constant_path)).to include('Constants$Constant', 'https://old.example') + expect(File.read(File.join(root, 'domain', 'model.rb'))).to include('dtos', 'oql_views') + expect(File.read(File.join(root, 'application', 'application.rb'))).to include('datasets') + + File.write(view_path, File.read(view_path).sub('SELECT Name', 'SELECT Name, Code')) + File.write(constant_path, File.read(constant_path).sub('https://old.example', 'https://new.example')) + generate(exported, rebuilt) + + expect(Mxrb.validate(rebuilt)).to be_valid + Mxrb.open(rebuilt) do |project| + expect(project.modules.first.entities.map(&:name)).to contain_exactly( + 'LegacyView', 'Product', 'ProductDTO', 'ProductView' + ) + query = project.oql_queries.find { _1.name == 'ProductViewSource' } + expect(query.oql).to include('SELECT Name, Code') + expect(project.oql_queries.find { _1.name == 'ProductData' }.kind).to eq(:dataset) + expect(project.oql_queries.find { _1.name == 'LegacyView' }.kind).to eq(:view_entity) + expect(project.modules.first.enumerations.map { _1['Name'] }).to include('LocationType') + expect(project.modules.first.constants.find { _1['Name'] == 'ApiAddress' }) + .to include('DefaultValue' => 'https://new.example') + end + end + end + + def define_domain_project(path) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + Mxrb.define(path) do + mendix_version '11.12.1' + self.module(:API_Rest) do + entity(:Product) { string :Name } + entity(:ProductDTO) do + non_persistent! + string :Name + end + entity(:ProductView) do + oql_view source: 'API_Rest.ProductViewSource' + string :Name + end + entity(:LegacyView) do + oql_view query: 'SELECT Name FROM API_Rest.Product' + string :Name + end + enumeration(:LocationType) { value :Warehouse, caption: 'Warehouse' } + constant :ApiAddress, type: :string, value: 'https://old.example' + native_document :ProductViewSource, + type: 'DomainModels$ViewEntitySourceDocument', + deep_structure: { + 'Oql' => 'SELECT Name FROM API_Rest.Product' + } + native_document :ProductData, type: 'DataSets$DataSet', deep_structure: { + 'Source' => { + '$Type' => 'DataSets$OqlDataSetSource', + 'Query' => 'SELECT Name FROM API_Rest.Product' + } + } + end + end + end + + it 'requires an OQL source or inline query' do + builder = Mxrb::Dsl::EntityBuilder.new(:BrokenView) + expect { builder.oql_view }.to raise_error(ArgumentError, /requires source or query/) + end + + it 'keeps a native view entity recognizable when it has no editable query field' do + entity = Mxrb::Model::Entity.new + entity.id = SecureRandom.uuid + entity.name = 'OpaqueView' + entity.persistable = false + entity.documentation = '' + entity.native_type = 'DomainModels$ViewEntity' + entity.access_rules = [] + entity.indexes = [] + entity.system_members = {} + mod = Struct.new(:name, :entities).new('API_Rest', [entity]) + + source = Mxrb::Exporter.allocate.send(:entity_source, entity, mod, []) + expect(source).to include('entity :OpaqueView', 'non_persistent!') + expect(source).not_to include('oql_view ') + end + + def generate(exported, rebuilt) + previous = ENV['MXRB_OUTPUT_PATH'] + ENV['MXRB_OUTPUT_PATH'] = rebuilt + load File.join(exported, 'project.rb') + ensure + ENV['MXRB_OUTPUT_PATH'] = previous + end +end +# rubocop:enable Metrics/BlockLength diff --git a/spec/exported_mappings_spec.rb b/spec/exported_mappings_spec.rb new file mode 100644 index 0000000..4bb571e --- /dev/null +++ b/spec/exported_mappings_spec.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +EXPORTED_MAPPING_TYPES = { + exports: 'ExportMappings$ExportMapping', + imports: 'ImportMappings$ImportMapping', + json_structures: 'JsonStructures$JsonStructure' +}.freeze + +# rubocop:disable Metrics/BlockLength +RSpec.describe 'exported mapping documents' do + it 'creates and extends layer aggregators idempotently' do + Dir.mktmpdir('mxrb-aggregator-') do |dir| + path = File.join(dir, 'application.rb') + exporter = Mxrb::Exporter.allocate + exporter.send(:append_to_aggregator, path, ['queries/one.rb']) + exporter.send(:append_to_aggregator, path, ['queries/one.rb', 'queries/two.rb']) + expect(File.read(path).lines.grep(/one\.rb/).size).to eq(1) + expect(File.read(path)).to include('two.rb') + end + end + + it 'honors an existing requested container for a new native document' do + Dir.mktmpdir('mxrb-native-container-') do |dir| + path = File.join(dir, 'Container.mpr') + Mxrb.define(path) { self.module(:Integration) {} } + mpr = Mxrb::IO::MprFile.open(path) + module_id = mpr.units_by_containment('Modules').first.fetch('UnitID') + folder_id = mpr.insert_unit( + container_uuid: module_id, containment_name: 'Folders', + contents_doc: { '$Type' => 'Projects$Folder', 'Name' => 'Resources' } + ) + definition = { + native_documents: [{ + name: 'Payload', type: 'JsonStructures$JsonStructure', + containment: 'Documents', unit_id: nil, container_id: folder_id, + doc: { '$Type' => 'JsonStructures$JsonStructure', 'Name' => 'Payload' } + }] + } + Mxrb::Writer.allocate.send(:write_native_documents, mpr, module_id, definition) + + expect(mpr.children_of(folder_id).map { mpr.parse_contents(_1)['Name'] }).to include('Payload') + ensure + mpr&.close + end + end + + it 'exports mappings as layered Ruby and updates them without moving or duplicating units' do + Dir.mktmpdir('mxrb-exported-mappings-') do |dir| + source = File.join(dir, 'Mappings.mpr') + exported = File.join(dir, 'ruby') + rebuilt = File.join(dir, 'rebuilt.mpr') + Mxrb.define(source) do + mendix_version '11.12.1' + self.module(:Integration) {} + end + add_mapping_documents(source) + + Mxrb.open(source) do |project| + expect(project.modules.first.mapping_documents.map { _1[:type] }) + .to contain_exactly(*EXPORTED_MAPPING_TYPES.values) + end + + exporter = Mxrb::Exporter.new(source, exported) + used = { File.join('mappings', 'same.rb') => true } + expect(exporter.send(:unique_relative_path, 'mappings', 'same', used)) + .to eq(File.join('mappings', 'same_2.rb')) + exporter.export! + files = EXPORTED_MAPPING_TYPES.keys.to_h do |category| + [category, Dir[File.join(exported, 'modules', 'Integration', 'infrastructure', + 'mappings', category.to_s, '*.rb')].fetch(0)] + end + expect(File.read(files.fetch(:exports))).to include('type: "ExportMappings$ExportMapping"') + expect(File.read(files.fetch(:imports))).to include('unit_id:', 'container_id:') + expect(File.read(files.fetch(:json_structures))).to include('bson_binary(') + native_source = File.read(File.join(exported, '.mxrb', 'native_units.rb')) + EXPORTED_MAPPING_TYPES.each_value { expect(native_source).not_to include(_1) } + + import_path = files.fetch(:imports) + File.write(import_path, File.read(import_path).sub('"Marker" => "before"', + '"Marker" => "after"')) + generate(exported, rebuilt) + + expect(Mxrb.validate(rebuilt)).to be_valid + Mxrb.open(rebuilt) do |project| + documents = project.all_units.filter_map do |unit| + doc = project.parse_bson(unit) + [unit, doc] if EXPORTED_MAPPING_TYPES.value?(doc['$Type']) + end + expected = EXPORTED_MAPPING_TYPES.values.to_h { [_1, 1] } + expect(documents.map { _2['$Type'] }.tally).to eq(expected) + imported = documents.find { _2['$Type'] == EXPORTED_MAPPING_TYPES[:imports] } + expect(imported.last['Marker']).to eq('after') + documents.map(&:first).each do |unit| + parent = project.parse_bson(project.raw_unit(unit.fetch('ContainerID'))) + expect(parent).to include('$Type' => 'Projects$Folder', 'Name' => 'Mappings') + end + end + end + end + + it 'exports a published REST service and all of its routes under endpoints' do + Dir.mktmpdir('mxrb-exported-endpoints-') do |dir| + source = File.join(dir, 'Api.mpr') + exported = File.join(dir, 'ruby') + rebuilt = File.join(dir, 'rebuilt.mpr') + Mxrb.define(source) { self.module(:API_Rest) {} } + add_published_rest_service(source) + + Mxrb::Exporter.new(source, exported).export! + endpoint = File.join(exported, 'modules', 'API_Rest', 'infrastructure', + 'endpoints', 'api_service.rb') + ruby = File.read(endpoint) + expect(ruby).to include( + 'Rest$PublishedRestService', 'HttpMethod', 'Operations', 'orders/{id}', + '"Version" => "1.0.0"', '"EnableCors" => true', '"RequiresAuthentication" => true', + '"AllowedRoles"', '"HttpMethod" => "Post"' + ) + infrastructure = File.read(File.join(File.dirname(File.dirname(endpoint)), 'infrastructure.rb')) + expect(infrastructure).to include('endpoints', 'api_service.rb') + consumed = File.join(exported, 'modules', 'API_Rest', 'infrastructure', + 'integrations', 'master_entity_domain.rb') + expect(File.read(consumed)).to include('Rest$ConsumedODataService', 'catalog.example') + + File.write(endpoint, ruby.sub('"Path" => "orders/{id}"', '"Path" => "orders/{orderId}"')) + generate(exported, rebuilt) + expect(Mxrb.validate(rebuilt)).to be_valid + Mxrb.open(rebuilt) do |project| + services = project.all_units.filter_map do |unit| + doc = project.parse_bson(unit) + doc if doc['$Type'] == 'Rest$PublishedRestService' + end + expect(services.size).to eq(1) + operation = Mxrb::IO::BsonCodec.parse_array(services.first['Resources'])[:items] + .flat_map { Mxrb::IO::BsonCodec.parse_array(_1['Operations'])[:items] } + .first + expect(operation['Path']).to eq('orders/{orderId}') + end + end + end + + def add_mapping_documents(path) # rubocop:disable Metrics/MethodLength + mpr = Mxrb::IO::MprFile.open(path) + module_id = mpr.units_by_containment('Modules').first.fetch('UnitID') + folder_id = mpr.insert_unit( + container_uuid: module_id, containment_name: 'Folders', + contents_doc: { '$Type' => 'Projects$Folder', 'Name' => 'Mappings' } + ) + EXPORTED_MAPPING_TYPES.each_value do |type| + name = type.split('$').last + mpr.insert_unit( + container_uuid: folder_id, containment_name: 'Documents', + contents_doc: { + '$Type' => type, 'Name' => name, 'Marker' => 'before', + 'Blob' => BSON::Binary.new('mapping-bytes') + } + ) + end + ensure + mpr&.close + end + + def generate(exported, rebuilt) + previous = ENV['MXRB_OUTPUT_PATH'] + ENV['MXRB_OUTPUT_PATH'] = rebuilt + load File.join(exported, 'project.rb') + ensure + ENV['MXRB_OUTPUT_PATH'] = previous + end + + def add_published_rest_service(path) # rubocop:disable Metrics/MethodLength + mpr = Mxrb::IO::MprFile.open(path) + module_id = mpr.units_by_containment('Modules').first.fetch('UnitID') + operation = { + '$ID' => SecureRandom.uuid, '$Type' => 'Rest$PublishedRestServiceOperation', + 'HttpMethod' => 'Get', 'Path' => 'orders/{id}', 'Microflow' => 'API_Rest.GetOrder' + } + post_operation = operation.merge( + '$ID' => SecureRandom.uuid, 'HttpMethod' => 'Post', + 'Path' => 'orders', 'Microflow' => 'API_Rest.CreateOrder' + ) + resource = { + '$ID' => SecureRandom.uuid, '$Type' => 'Rest$PublishedRestServiceResource', + 'Name' => 'Orders', + 'Operations' => Mxrb::IO::BsonCodec.build_array([operation, post_operation], marker: 2) + } + mpr.insert_unit( + container_uuid: module_id, containment_name: 'Documents', + contents_doc: { + '$Type' => 'Rest$PublishedRestService', 'Name' => 'API_Service', + 'Version' => '1.0.0', 'Path' => 'rest/orders/v1', 'EnableCors' => true, + 'RequiresAuthentication' => true, + 'AllowedRoles' => Mxrb::IO::BsonCodec.build_array(%w[API_Rest.Admin API_Rest.User]), + 'Resources' => Mxrb::IO::BsonCodec.build_array([resource], marker: 3) + } + ) + mpr.insert_unit( + container_uuid: module_id, containment_name: 'Documents', + contents_doc: { + '$Type' => 'Rest$ConsumedODataService', 'Name' => 'MasterEntityDomain', + 'MetadataUrl' => 'https://catalog.example/$metadata' + } + ) + ensure + mpr&.close + end +end +# rubocop:enable Metrics/BlockLength diff --git a/spec/mxrb_spec.rb b/spec/mxrb_spec.rb index 875ad34..069b5a6 100644 --- a/spec/mxrb_spec.rb +++ b/spec/mxrb_spec.rb @@ -802,9 +802,11 @@ def make_mpr(path) mpr.close Mxrb::Exporter.new(source, exported).export! - ruby_path = File.join(exported, ".mxrb", "native_units.rb") + ruby_path = File.join( + exported, "modules", "Sales", "domain", "constants", "endpoint.rb" + ) source_code = File.read(ruby_path) - expect(source_code).to include("native_unit ", '"Constants$Constant"', "bson_binary(") + expect(source_code).to include("native_document ", '"Constants$Constant"', "bson_binary(") File.write(ruby_path, source_code.sub('"Value" => "before"', '"Value" => "after"')) begin diff --git a/spec/progress_spec.rb b/spec/progress_spec.rb new file mode 100644 index 0000000..17be477 --- /dev/null +++ b/spec/progress_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require 'stringio' +require 'spec_helper' + +ProgressTtyBuffer = Class.new(StringIO) do + def tty? = true +end + +ProgressNarrowBuffer = Class.new(ProgressTtyBuffer) do + def winsize = [24, 18] +end + +ProgressBrokenWinsizeBuffer = Class.new(ProgressTtyBuffer) do + def winsize = raise(Errno::ENOTTY) +end + +ProgressZeroWinsizeBuffer = Class.new(ProgressTtyBuffer) do + def winsize = [24, 0] +end + +ProgressPlainBuffer = Class.new do + attr_reader :string + + def initialize = (@string = +'') + def write(value) = (@string << value) + def tty? = false +end + +ProgressBrokenWriteBuffer = Class.new(ProgressPlainBuffer) do + def write(*) = raise(IOError) +end + +# rubocop:disable Metrics/BlockLength +RSpec.describe Mxrb::Progress do + after { described_class.reset! } + + it 'renders determinate progress and completes at 100 percent' do + output = ProgressTtyBuffer.new + described_class.configure(enabled: true, io: output) + + result = described_class.with('Exporting App.mpr', total: 4) do |progress| + progress.advance(detail: 'native units', force: true) + progress.advance(detail: 'modules', force: true) + :done + end + + expect(result).to eq(:done) + expect(output.string).to include('Exporting App.mpr', '25%', '50%', '100%') + end + + it 'does not render when progress is disabled' do + output = ProgressTtyBuffer.new + described_class.configure(enabled: false, io: output) + + task = nil + described_class.with('Loading') { task = _1 } + + expect(task).not_to be_enabled + expect(output.string).to be_empty + end + + it 'marks a failed operation and reraises its error' do + output = ProgressTtyBuffer.new + described_class.configure(enabled: true, io: output) + + expect do + described_class.with('Importing') { raise Mxrb::Error, 'invalid package' } + end.to raise_error(Mxrb::Error, 'invalid package') + expect(output.string).to include('FAILED', 'Importing', 'invalid package') + end + + it 'supports dynamic totals, clamping, nested operations, and every terminal shape' do + output = ProgressTtyBuffer.new + task = described_class::Task.new('Loading a dynamic operation', io: output).start + sleep(described_class::Task::REFRESH_INTERVAL * 2) + expect(task.update(current: -2, total: 0, detail: 'discovered', force: true)).to equal(task) + expect(task.update(current: 99, force: true)).to equal(task) + expect(task.update(force: true)).to equal(task) + expect(task.add_total(2)).to equal(task) + expect(task.advance(1, force: true)).to equal(task) + expect(task.finish('complete')).to equal(task) + expect(task.finish).to equal(task) + expect(output.string).to include('discovered', '100%') + + indeterminate = described_class::Task.new('Static spinner', io: ProgressPlainBuffer.new) + indeterminate.advance + indeterminate.advance + indeterminate.add_total(1) + indeterminate.fail + expect(indeterminate.fail('ignored')).to equal(indeterminate) + described_class::Task.new('No known total', io: ProgressPlainBuffer.new).finish + + narrow = ProgressNarrowBuffer.new + described_class::Task.new('A label that must be truncated', total: 1, io: narrow).start.finish + expect(narrow.string).to include('...') + described_class::Task.new( + 'winsize fallback', total: 1, io: ProgressBrokenWinsizeBuffer.new + ).start.finish + described_class::Task.new('zero winsize', total: 1, io: ProgressZeroWinsizeBuffer.new).start.finish + described_class::Task.new('broken output', io: ProgressBrokenWriteBuffer.new).start + end + + it 'covers automatic enablement, environment opt-out, current task, and nested reuse' do + output = ProgressTtyBuffer.new + previous = ENV['MXRB_PROGRESS'] + described_class.reset! + described_class.configure(io: output) + expect(described_class).to be_enabled + ENV['MXRB_PROGRESS'] = 'off' + expect(described_class).not_to be_enabled + ENV['MXRB_PROGRESS'] = '' + + described_class.configure(io: Object.new) + expect(described_class).not_to be_enabled + described_class.configure(io: output) + + seen = [] + described_class.with('Outer', total: 1) do + seen << described_class.current + described_class.with('Inner') { seen << _1 } + end + expect(seen.uniq.size).to eq(1) + expect(described_class.current).to equal(described_class::NullTask.instance) + + described_class.configure(enabled: true) + described_class.configure(enabled: nil, io: nil) + expect(described_class).to be_enabled + ensure + ENV['MXRB_PROGRESS'] = previous + end +end +# rubocop:enable Metrics/BlockLength