diff --git a/Gemfile b/Gemfile index 2037caf..1ebe644 100644 --- a/Gemfile +++ b/Gemfile @@ -15,6 +15,10 @@ gemspec actual_path = sibling_gem == "emf" ? File.expand_path("~/src/claricle/emf") : actual_path if actual_path && File.directory?(actual_path) && ENV["EA_FORCE_RUBYGEMS"] != "1" gem sibling_gem, path: actual_path + elsif sibling_gem == "xmi" + # xmi 0.7.0 is unreleased; resolve the companion branch until it + # ships, then delete this elsif so xmi comes from rubygems again. + gem sibling_gem, github: "lutaml/xmi", branch: "feat/nested-classifier-mapping" else gem sibling_gem end diff --git a/ea.gemspec b/ea.gemspec index 17884b3..5a13e62 100644 --- a/ea.gemspec +++ b/ea.gemspec @@ -36,7 +36,14 @@ Gem::Specification.new do |spec| spec.add_dependency "lutaml-path" spec.add_dependency "sqlite3" spec.add_dependency "rubyzip" - spec.add_dependency "xmi", "~> 0.6", ">= 0.6.0" + # Requires unreleased xmi features (nestedClassifier, lowerValue-first + # order); CI installs them from the companion branch (see Gemfile). + # A released xmi (e.g. 0.6.2) satisfies this constraint but would + # silently drop nested classes, so lib/ea/xmi_guard.rb raises at + # require time when the loaded xmi lacks nestedClassifier support. + # Tighten to "~> 0.7" (and delete the guard) once xmi 0.7.0 is + # released. + spec.add_dependency "xmi", ">= 0.6.2" spec.add_dependency "nokogiri", "~> 1.18" spec.add_dependency "liquid" spec.add_dependency "thor", "~> 1.4" diff --git a/lib/ea.rb b/lib/ea.rb index 542c432..cd4fe93 100644 --- a/lib/ea.rb +++ b/lib/ea.rb @@ -63,3 +63,9 @@ def to_uml(path_or_model) end end end + +# Boot-time guard: fail loudly if the resolved xmi gem lacks the +# unreleased features this gem depends on (see lib/ea/xmi_guard.rb and +# the xmi dependency comment in ea.gemspec). Required last so +# Ea::Error is defined when the guard raises. +require "ea/xmi_guard" diff --git a/lib/ea/cli/app.rb b/lib/ea/cli/app.rb index 38d5f0a..a2f5af6 100644 --- a/lib/ea/cli/app.rb +++ b/lib/ea/cli/app.rb @@ -120,6 +120,7 @@ def render(name = nil, file = nil) desc "export SUB FILE", "Export model (SUB: xmi|json|plantuml|xsd)" option :output, **OUTPUT_OPTION, desc: "Output path" option :package, type: :string, desc: "Restrict to a package name" + option :mdg, type: :array, desc: "MDG technology XML files or directories (xmi only)" def export(sub, file) Command::Export.new(sub: sub, file: file, **symbolize(options)).call end diff --git a/lib/ea/cli/command/export.rb b/lib/ea/cli/command/export.rb index 2e3ac3c..f34fb23 100644 --- a/lib/ea/cli/command/export.rb +++ b/lib/ea/cli/command/export.rb @@ -39,7 +39,7 @@ def exporter # a string. New formats add a new entry here without touching # this class body. Lambdas lazy-resolve autoloaded namespaces. EXPORTERS = { - xmi: ->(model, **_o) { Ea::Transformers.qea_to_xmi(model) }, + xmi: ->(model, **o) { Ea::Transformers.qea_to_xmi(model, mdg_registry: o[:mdg_registry]) }, json: ->(model, **o) { Ea::Export::Json::Generator.call(model, **o) }, "json-schema": ->(model, **o) { Ea::Export::JsonSchema::Generator.call(model, **o) }, plantuml: ->(model, **o) { Ea::Export::PlantUml::Generator.call(model, **o) }, @@ -71,9 +71,18 @@ def exporter_options opts = {} opts[:target_namespace] = options[:target_namespace] if options[:target_namespace] opts[:prefix] = options[:prefix] if options[:prefix] + add_mdg_registry(opts) opts end + # Registry construction is xmi-only: the other exporters reject + # unknown keywords. + def add_mdg_registry(opts) + return unless options[:mdg] && sub == :xmi + + opts[:mdg_registry] = Ea::Mdg::Registry.from_paths(options[:mdg]) + end + def model @model ||= load_database(file_path) end diff --git a/lib/ea/cli/command/mdg.rb b/lib/ea/cli/command/mdg.rb index cf15fd1..bc338e0 100644 --- a/lib/ea/cli/command/mdg.rb +++ b/lib/ea/cli/command/mdg.rb @@ -39,16 +39,7 @@ def registry end def build_registry - registry = Ea::Mdg::Registry.new - Dir.glob("spec/fixtures/mdg/**/*.xml").each do |path| - begin - doc = Ea::Mdg::Loader.from_path(path).document - registry.register(doc) - rescue StandardError => e - warn "Skipped #{path}: #{e.message}" if ENV["EA_DEBUG"] - end - end - registry + Ea::Mdg::Registry.from_paths(["spec/fixtures/mdg"]) end def list_technologies diff --git a/lib/ea/guid_format.rb b/lib/ea/guid_format.rb index fb7de56..ed72e0e 100644 --- a/lib/ea/guid_format.rb +++ b/lib/ea/guid_format.rb @@ -71,5 +71,21 @@ def connector_end_xmi_id(connector_xmi_id, side:) trimmed = first_segment.length > 2 ? first_segment[2..] : first_segment rest ? "EAID_#{tag}#{trimmed}_#{rest}" : "EAID_#{tag}#{trimmed}" end + + # Build the xmi:id for an operation's synthesized return parameter. + # + # Sparx EA's convention: take the operation's own id and replace its + # first GUID segment with `RETURNID`. An id carrying no further + # segments has nothing to drop, so its only segment is kept. The + # parameter's ea_guid is the braced form of this id, so callers + # derive it with {xmi_id_to_ea_guid} rather than spelling the shape + # a second time. + # + # @param operation_xmi_id [String] e.g. "EAID_AB12CDEF_3456_..." + # @return [String] e.g. "EAID_RETURNID_3456_..." + def return_parameter_xmi_id(operation_xmi_id) + body = operation_xmi_id.sub(/\A(?:EAID|EAPK)_/, "") + "EAID_RETURNID_#{body.split("_", 2).last}" + end end end diff --git a/lib/ea/mdg/registry.rb b/lib/ea/mdg/registry.rb index d12662e..6dcb903 100644 --- a/lib/ea/mdg/registry.rb +++ b/lib/ea/mdg/registry.rb @@ -26,6 +26,27 @@ def initialize @documents = [] end + # Build a registry from MDG XML files and/or directories + # (directories are globbed recursively). Unloadable files are + # skipped — MDG discovery is a boundary where partial input is + # normal (set EA_DEBUG=1 to see what was skipped). + # + # @param paths [Array] + # @return [Ea::Mdg::Registry] + def self.from_paths(paths) + new.tap do |registry| + paths.flat_map { |path| expand_mdg_path(path) }.each do |file| + registry.register(Loader.from_path(file).document) + rescue StandardError => e + warn "Skipped MDG #{file}: #{e.message}" if ENV["EA_DEBUG"] + end + end + end + + def self.expand_mdg_path(path) + File.directory?(path) ? Dir.glob(File.join(path, "**/*.xml")).sort : [path] + end + # Register an MDG Document. Subsequent registrations of the # same technology_name replace the prior one (last-wins). # diff --git a/lib/ea/qea/database.rb b/lib/ea/qea/database.rb index 123db55..b07c78d 100644 --- a/lib/ea/qea/database.rb +++ b/lib/ea/qea/database.rb @@ -212,9 +212,19 @@ def diagram_links_for(id) @diagram_links_by_id[id] || [] end - # Find an object by ID + # Find an object by ID. Uses the repository's primary-key index + # rather than a scan: the parentid walk in QeaToXmi's + # `cyclic_ancestry?` calls this once per ancestor hop per object. + # + # Hash lookup matches on eql?, so it would miss a Float id that a + # scan's == would have caught. No caller here can do that: + # t_object.Object_ID is declared Type::Integer and the model + # coerces on construction, and every caller passes either such an + # attribute or an explicit to_i. Deliberately left unguarded — a + # scan fallback on miss would put every absent-parent lookup in + # the ancestry walk back on a linear scan. def find_object(id) - objects.find_by_key(:ea_object_id, id) + objects.find(id) end # Find object by ea_guid diff --git a/lib/ea/qea/models/ea_connector.rb b/lib/ea/qea/models/ea_connector.rb index 335048b..425a898 100644 --- a/lib/ea/qea/models/ea_connector.rb +++ b/lib/ea/qea/models/ea_connector.rb @@ -119,10 +119,11 @@ def aggregation? connector_type == "Aggregation" end - # Check if connector is a realization + # Check if connector is a realization. EA writes both the US + # and UK spellings depending on version/edition. # @return [Boolean] def realization? - connector_type == "Realization" + %w[Realization Realisation].include?(connector_type) end # Check if source is aggregate diff --git a/lib/ea/qea/models/ea_object.rb b/lib/ea/qea/models/ea_object.rb index 58683fb..8b3cf91 100644 --- a/lib/ea/qea/models/ea_object.rb +++ b/lib/ea/qea/models/ea_object.rb @@ -194,17 +194,22 @@ def leaf? # Note — diagram note. Same situation as Text: rendering # hint, not a model element. Dropped. # + TRANSFORMER_TYPES = { + "Enumeration" => :enumeration, + "DataType" => :data_type, + "PrimitiveType" => :data_type, + "Class" => :class, + "Interface" => :class, + "Signal" => :signal, + "Object" => :instance, + "Association" => :association_element, + }.freeze + # @return [Symbol, nil] Registry key or nil if not a UML model element def transformer_type - if enumeration? || stereotype_is?("enumeration") - :enumeration - elsif data_type? - :data_type - elsif uml_class? || interface? - :class - elsif instance? - :instance - end + return :enumeration if stereotype_is?("enumeration") + + TRANSFORMER_TYPES[object_type] end def stereotype_is?(expected) diff --git a/lib/ea/qea/models/ea_xref.rb b/lib/ea/qea/models/ea_xref.rb index 4367fa8..1fb8e19 100644 --- a/lib/ea/qea/models/ea_xref.rb +++ b/lib/ea/qea/models/ea_xref.rb @@ -10,6 +10,9 @@ class EaXref < BaseModel attribute :xref_id, Lutaml::Model::Type::String attribute :name, Lutaml::Model::Type::String attribute :xref_type, Lutaml::Model::Type::String + attribute :visibility, Lutaml::Model::Type::String + attribute :behavior, Lutaml::Model::Type::String + attribute :partition, Lutaml::Model::Type::String attribute :client, Lutaml::Model::Type::String attribute :supplier, Lutaml::Model::Type::String attribute :description, Lutaml::Model::Type::String diff --git a/lib/ea/qea/repositories/base_repository.rb b/lib/ea/qea/repositories/base_repository.rb index cbaaea7..4b9cab5 100644 --- a/lib/ea/qea/repositories/base_repository.rb +++ b/lib/ea/qea/repositories/base_repository.rb @@ -215,9 +215,12 @@ def size private + # First occurrence wins, matching what a scan would return — + # `find` and `find_by_key` must not disagree about which record + # answers for a duplicated key. def build_pk_index @pk_index = {} - @records.each { |r| @pk_index[r.primary_key] = r } + @records.each { |r| @pk_index[r.primary_key] ||= r } end end end diff --git a/lib/ea/transformers/qea_to_xmi.rb b/lib/ea/transformers/qea_to_xmi.rb index ad8532f..01c5f36 100644 --- a/lib/ea/transformers/qea_to_xmi.rb +++ b/lib/ea/transformers/qea_to_xmi.rb @@ -25,6 +25,7 @@ module QeaToXmi autoload :RunState, "ea/transformers/qea_to_xmi/run_state" autoload :AssociationEnd, "ea/transformers/qea_to_xmi/association_end" autoload :ExtensionSerializer, "ea/transformers/qea_to_xmi/extension_serializer" + autoload :PrimitiveTypes, "ea/transformers/qea_to_xmi/primitive_types" autoload :ProfileSerializer, "ea/transformers/qea_to_xmi/profile_serializer" end end diff --git a/lib/ea/transformers/qea_to_xmi/cardinality.rb b/lib/ea/transformers/qea_to_xmi/cardinality.rb index 3c0591d..def8ce6 100644 --- a/lib/ea/transformers/qea_to_xmi/cardinality.rb +++ b/lib/ea/transformers/qea_to_xmi/cardinality.rb @@ -13,21 +13,42 @@ module QeaToXmi # ``) — never a range string. This module # translates the EA form to a `{ lower:, upper: }` pair. # - # Default-when-empty: EA's "no bound specified" maps to UML's - # unspecified multiplicity, which Sparx renders as - # `` and ``. - # Always emitting both is required for round-trip parity with - # real Sparx XMI (see TODO 26). + # Default-when-empty: blank input maps to UML's unspecified + # multiplicity, `{ lower: "0", upper: "*" }`. Whether those + # bounds reach the document is the caller's call — EA leaves an + # association end bare when its card field is blank. module Cardinality # Tokens EA uses for "unbounded". Matched case-insensitively. - UNLIMITED_TOKENS = %w[* *-1 unbounded].freeze + UNLIMITED_TOKENS = %w[* *-1 -1 unbounded].freeze - # UML defaults when EA carries no explicit bound. + # UML defaults when EA carries no explicit bound. EA's wire + # form for unlimited is "*" (never "-1" — the reference + # exports contain no value="-1" at all). DEFAULT_LOWER = "0" - DEFAULT_UPPER = "-1" + DEFAULT_UPPER = "*" + + # EA writes an explicit 1..1 for a Property whose t_attribute + # bound columns are BOTH blank, rather than falling back to the + # UML unspecified multiplicity. + DEFAULT_ATTRIBUTE_BOUNDS = { lower: "1", upper: "1" }.freeze module_function + # The bound pair for a t_attribute row. The 1..1 default is a + # property of the PAIR — with one column set, the missing side + # keeps the normal UML fallback, so `2` and a blank upper is + # 2..* rather than the invalid 2..1. + # + # @param lower [String, Integer, nil] t_attribute.lowerbound + # @param upper [String, Integer, nil] t_attribute.upperbound + # @return [Hash{Symbol=>String}] `{ lower:, upper: }` + def attribute_bounds(lower, upper) + both_blank = lower.to_s.strip.empty? && upper.to_s.strip.empty? + return DEFAULT_ATTRIBUTE_BOUNDS if both_blank + + { lower: normalize_lower(lower), upper: normalize_upper(upper) }.freeze + end + # @param raw [String, nil] e.g. "1..*", "0..1", "1", "*", nil # @return [Hash{Symbol=>String}] `{ lower:, upper: }` always # populated; never nil. Empty/nil input returns the UML default. @@ -38,17 +59,14 @@ def parse(raw) return parse_range(stripped) if stripped.include?("..") # Bare unlimited token (e.g. "*") means "many" — lower bound - # is unspecified, which UML renders as 0..-1. Returning - # `{ lower: "-1", upper: "-1" }` here would be invalid: - # LiteralInteger cannot hold -1. + # is unspecified, which renders as 0..*. return defaults if UNLIMITED_TOKENS.include?(stripped.downcase) - single = normalize_bound(stripped) - { lower: single, upper: single } + { lower: stripped, upper: stripped } end - # Normalise an upper-bound token: `*` / `unbounded` → `-1` - # (UML LiteralUnlimitedNatural wire form). + # Normalise an upper-bound token: `*` / `unbounded` → `*` + # (the LiteralUnlimitedNatural wire form EA also uses). # @param raw [String, Integer, nil] # @return [String] def normalize_upper(raw) @@ -57,17 +75,21 @@ def normalize_upper(raw) stripped = raw.to_s.strip return DEFAULT_UPPER if stripped.empty? - UNLIMITED_TOKENS.include?(stripped.downcase) ? "-1" : stripped + UNLIMITED_TOKENS.include?(stripped.downcase) ? "*" : stripped end # Normalise a lower-bound token: empty/nil → "0" (UML default). + # Unlimited tokens also → "0" — a lower bound serializes as + # uml:LiteralInteger, which cannot hold `*` or `-1`. # @param raw [String, Integer, nil] # @return [String] def normalize_lower(raw) return DEFAULT_LOWER if raw.nil? stripped = raw.to_s.strip - stripped.empty? ? DEFAULT_LOWER : stripped + return DEFAULT_LOWER if stripped.empty? + + UNLIMITED_TOKENS.include?(stripped.downcase) ? DEFAULT_LOWER : stripped end # ---- Internal helpers ---- @@ -78,17 +100,7 @@ def defaults def parse_range(stripped) lower, upper = stripped.split("..", 2) - { lower: normalize_bound(lower), upper: normalize_bound(upper) } - end - - # A single bound token (one side of `..` or a bare scalar). - # Empty / `*` → UML unlimited (`-1`). - def normalize_bound(token) - return "-1" if token.nil? - return "-1" if token.strip.empty? - - stripped = token.strip - UNLIMITED_TOKENS.include?(stripped.downcase) ? "-1" : stripped + { lower: normalize_lower(lower), upper: normalize_upper(upper) } end end end diff --git a/lib/ea/transformers/qea_to_xmi/context.rb b/lib/ea/transformers/qea_to_xmi/context.rb index 53be516..581dae7 100644 --- a/lib/ea/transformers/qea_to_xmi/context.rb +++ b/lib/ea/transformers/qea_to_xmi/context.rb @@ -9,6 +9,11 @@ module QeaToXmi # - ID-derivation helpers (`xmi_id_for`, `end_xmi_id_for`) backed by # {GuidFormat} # - delegated database lookups (objects, packages, attributes, etc.) + # - the walk's position ordering (`sorted_by_position`), applied at + # the lookup for operation parameters so the UML tree and the + # extension block cannot disagree about their order. Attributes + # and operations are deliberately left for their callers to + # order — see `attributes_for`. # # The {Ea::Qea::Database} already maintains its own lookup indexes # (object-by-id, connectors-by-object, attributes-by-object, etc.). @@ -33,6 +38,17 @@ def xmi_id_for(record, prefix: "EAID") GuidFormat.ea_guid_to_xmi_id(record.ea_guid, prefix: prefix) end + # Whether a record can anchor a synthesized id. EA derives a + # synthesized id's tail from its owner's GUID, so an owner + # without one yields a tailless id. Both the UML tree and the + # extension block ask this, so they cannot disagree about + # whether to emit the thing that id would have named. + # @param record [#ea_guid] + # @return [Boolean] + def identifiable?(record) + !record&.ea_guid.to_s.strip.empty? + end + # @param connector_xmi_id [String] # @param side [Symbol] :source or :destination # @return [String] @@ -66,6 +82,11 @@ def objects_in_package(package_id) database.objects_in_package(package_id) end + # Deliberately NOT ordered here, unlike params_for_operation: + # the extension block emits attributes in database order while + # the LI-bound preallocation sorts them by name, so each caller + # orders for itself. Sorting at this lookup would move export + # bytes. # @param object_id [Integer] # @return [Array] def attributes_for(object_id) @@ -78,10 +99,23 @@ def operations_for(object_id) database.operations_for_object(object_id) end + # t_operationparams is loaded with an unordered SELECT, and both + # the UML tree and the extension block emit parameters in Pos + # order. Ordering here means no caller can obtain them unordered. # @param operation_id [Integer] # @return [Array] def params_for_operation(operation_id) - database.operation_params_for(operation_id) + sorted_by_position(database.operation_params_for(operation_id)) + end + + # ---- Ordering ----------------------------------------------------- + + # EA emits a record's children in tree-position order (TPos / Pos), + # ties broken by name. + # @param records [Array] + # @return [Array] + def sorted_by_position(records) + records.sort_by { |record| [record.sort_position, record.name.to_s] } end # Connectors where the object is on either side. diff --git a/lib/ea/transformers/qea_to_xmi/extension_serializer.rb b/lib/ea/transformers/qea_to_xmi/extension_serializer.rb index afe45d1..54b3a2a 100644 --- a/lib/ea/transformers/qea_to_xmi/extension_serializer.rb +++ b/lib/ea/transformers/qea_to_xmi/extension_serializer.rb @@ -36,6 +36,7 @@ def call sections = [ build_elements_section, build_connectors_section, + build_primitivetypes_section, build_diagrams_section ].reject(&:empty?) sections.join("\n") @@ -96,6 +97,7 @@ def object_element_xml(obj) is_abstract: obj.abstract?), style_for_object(obj), tags_block_for(obj.ea_guid), + xrefs_xml("\t\t\t", obj.ea_guid, "element property"), attributes_block_for(obj), operations_block_for(obj), "\t\t" @@ -121,7 +123,9 @@ def skip_object?(obj) "DataType" => "uml:DataType", "PrimitiveType" => "uml:PrimitiveType", "Object" => "uml:Object", - "Package" => "uml:Package" + "Signal" => "uml:Signal", + "Package" => "uml:Package", + "Association" => "uml:Association" }.freeze def uml_type_for(obj) @@ -250,17 +254,24 @@ def attribute_xml(attr) "\t\t\t\t\t", "\t\t\t\t\t", "\t\t\t\t\t", - %(\t\t\t\t\t), + bounds_xml(attr), "\t\t\t\t\t", "\t\t\t\t\t