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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion ea.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 6 additions & 0 deletions lib/ea.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions lib/ea/cli/app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion lib/ea/cli/command/export.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down Expand Up @@ -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
Expand Down
11 changes: 1 addition & 10 deletions lib/ea/cli/command/mdg.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions lib/ea/guid_format.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 21 additions & 0 deletions lib/ea/mdg/registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>]
# @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).
#
Expand Down
14 changes: 12 additions & 2 deletions lib/ea/qea/database.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions lib/ea/qea/models/ea_connector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 14 additions & 9 deletions lib/ea/qea/models/ea_object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions lib/ea/qea/models/ea_xref.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion lib/ea/qea/repositories/base_repository.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/ea/transformers/qea_to_xmi.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 40 additions & 28 deletions lib/ea/transformers/qea_to_xmi/cardinality.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,42 @@ module QeaToXmi
# `<upperValue value="M"/>`) — 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
# `<lowerValue value="0"/>` and `<upperValue value="-1"/>`.
# 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.
Expand All @@ -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)
Expand All @@ -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 ----
Expand All @@ -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
Expand Down
Loading
Loading