Skip to content
Open
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
3 changes: 3 additions & 0 deletions lib/lutaml/lml.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ def self.compile(input, namespace: nil)
autoload :HasAttributes, "lutaml/lml/has_attributes"
autoload :VERSION, "lutaml/lml/version"

# Type classes (Lutaml::Model::Type::Value subclasses, not models)
autoload :LiteralValue, "lutaml/lml/literal_value"

# Model classes (in Lutaml::Lml namespace, files in models/ directory)
autoload :Action, "lutaml/lml/models/action"
autoload :Association, "lutaml/lml/models/association"
Expand Down
1 change: 1 addition & 0 deletions lib/lutaml/lml/data_processor/attribute_processing.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def process_attributes(obj)
end

def process_attributes_array(obj)
return [] if obj.empty?
return obj.map { |item| process_attributes(item) } unless single_key_hashes?(obj)

obj.each_with_object({}) do |item, hash|
Expand Down
13 changes: 11 additions & 2 deletions lib/lutaml/lml/data_processor/instance_processing.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ def process_instances(obj)
key = INSTANCE_KEY_HANDLERS.keys.find { |k| instance.key?(k) }
next unless key

result = public_send(INSTANCE_KEY_HANDLERS[key], instance[key])
key == :instance ? (acc[:instances] << result) : (acc[key] = result)
append_result(acc, key, public_send(INSTANCE_KEY_HANDLERS[key], instance[key]))
end
end

Expand Down Expand Up @@ -57,6 +56,16 @@ def handle_instance_attributes(value, result)
def handle_instance_template(value, result)
result[:template] = process_attributes(value[:attributes])
end

private

def append_result(acc, key, result)
case key
when :instance then acc[:instances] << result
when :collections then (acc[:collections] ||= []) << result
else (acc[key] ||= []).concat(result)
end
end
end
end
end
Expand Down
7 changes: 3 additions & 4 deletions lib/lutaml/lml/executor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,9 @@ def import_one(imp)
def validate_collections(doc, instances)
return [] unless doc.instances&.collections

collection = doc.instances.collections
return [] unless collection.is_a?(Collection)

ConditionEvaluator.evaluate(collection, instances)
Array(doc.instances.collections).flat_map do |collection|
ConditionEvaluator.evaluate(collection, instances)
end
end

# --- Export ---
Expand Down
2 changes: 1 addition & 1 deletion lib/lutaml/lml/executor/condition_evaluator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ class ConditionEvaluator
# Evaluate all validation conditions against a collection of instances.
# Returns an array of error strings (empty if all pass).
def self.evaluate(collection, instances)
return [] unless collection.validations&.any?
return [] unless collection.is_a?(Collection)
return [] unless collection.validations&.any?

new(instances).evaluate_all(collection.validations)
end
Expand Down
13 changes: 11 additions & 2 deletions lib/lutaml/lml/grammar/concerns/primitives.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,18 @@ module Primitives
rule(:whitespace?) { whitespace.maybe }
rule(:newline) { match('[\r\n]') }

rule(:quoted_string) do
str('"') >> (str('"').absent? >> any).repeat.as(:string) >> str('"')
# Body of a string delimited by `char`, captured as `label`.
def quoted(char, label)
str(char) >> (str(char).absent? >> any).repeat.as(label) >> str(char)
end

# A string in either quote style. Delimiters must match, so `"a'`
# stays a parse error.
def any_quoted(label)
quoted('"', label) | quoted("'", label)
end

rule(:quoted_string) { any_quoted(:string) }
rule(:boolean) { (str("true") | str("false")).as(:boolean) }
rule(:number) { (match("[0-9]").repeat(1) >> str(".") >> match("[0-9]").repeat(1)).as(:float) | match("[0-9]").repeat(1).as(:number) }
rule(:variable) { (quoted_string | match("[a-zA-Z0-9_]").repeat(1)) }
Expand Down
4 changes: 1 addition & 3 deletions lib/lutaml/lml/grammar/concerns/view_rules.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@ module ViewRules
rule(:view_import_keyword) { str("import") >> spaces }

rule(:view_import) do
view_import_keyword >>
str('"') >> (str('"').absent? >> any).repeat.as(:path) >> str('"') >>
whitespace?
view_import_keyword >> any_quoted(:path) >> whitespace?
end

rule(:entity_name_list) do
Expand Down
32 changes: 32 additions & 0 deletions lib/lutaml/lml/literal_value.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true

require "lutaml/model"

module Lutaml
module Lml
# The type of an LML attribute literal: a scalar, a list, or a map.
#
# This exists as a subclass rather than using Lutaml::Model::Type::Value
# directly because lutaml-model's key-value serializer tests the declared
# attribute type with a strict `attribute_type < Type::Value`. Type::Value
# is not a strict subclass of itself, so declaring it hands the serializer
# the raw wrapper object: JSON calls to_json(state) on it and raises
# ArgumentError, and Psych emits a !ruby/object tag that from_yaml then
# refuses to load. A subclass puts the attribute on the working path, where
# the serializer asks the wrapper for its value instead.
#
# Deleting this class needs two upstream changes, not one: that `<` widened
# to `<=`, and the serializer no longer re-wrapping a value it already
# wrapped. Widening `<` alone still leaves the nested wrapper below.
class LiteralValue < Lutaml::Model::Type::Value
# The serializer re-wraps an already-wrapped value before asking it for a
# format, so #value has to see through that extra layer. Every to_<format>
# method lutaml-model generates reads through here, which is why this is
# one override rather than one per format.
def value
wrapped = super
wrapped.is_a?(LiteralValue) ? wrapped.value : wrapped
end
end
end
end
2 changes: 1 addition & 1 deletion lib/lutaml/lml/models/instance_collection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class InstanceCollection < Lutaml::Model::Serializable
attribute :instances, "Lutaml::Lml::Instance", collection: true, default: []
attribute :imports, "Lutaml::Lml::InstancesImport", collection: true, default: []
attribute :exports, "Lutaml::Lml::InstancesExport", collection: true, default: []
attribute :collections, "Lutaml::Lml::Collection", default: []
attribute :collections, "Lutaml::Lml::Collection", collection: true, default: []
end
Comment thread
HassanAkbar marked this conversation as resolved.
end
end
65 changes: 64 additions & 1 deletion lib/lutaml/lml/models/top_element_attribute.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,73 @@ class TopElementAttribute < Lutaml::Model::Serializable

# LML-specific attributes
attribute :properties, "Lutaml::Lml::TopElementAttribute", collection: true, default: []
attribute :value, "Lutaml::Lml::TopElementAttribute", collection: true
attribute :value, LiteralValue
attribute :attributes, "Lutaml::Lml::TopElementAttribute", collection: true, default: []
attribute :extended, :boolean
attribute :instances, "Lutaml::Lml::Instance", collection: true, default: []

# Declared in full because a mapping block replaces the defaults. The
# rules below are the defaults, 1:1, except for `value`.
#
# `value` needs custom methods because a list literal reaches the model
# as a bare Array, and lutaml-model refuses one on a non-collection
# attribute: Attribute#cast splits it, then valid_collection! raises
# CollectionTrueMissingError. A custom `from` returns before that check,
# so a non-empty list survives a round trip without changing the
# serialized form. An empty list still does not: the transform skips a
# blank value before reaching the custom method, so `[]` reloads as nil.
key_value do
map "name", to: :name
map "visibility", to: :visibility
map "type", to: :type
map "id", to: :id
map "contain", to: :contain
map "static", to: :static
map "cardinality", to: :cardinality
map "keyword", to: :keyword
map "is_derived", to: :is_derived
map "is_static", to: :is_static
map "is_read_only", to: :is_read_only
map "stereotype", to: :stereotype
map "definition", to: :definition
map "association", to: :association
map "default", to: :default
map "properties", to: :properties
map "value", to: :value, with: { to: :value_to, from: :value_from }
map "attributes", to: :attributes
map "extended", to: :extended
map "instances", to: :instances
end

# Public because lutaml-model invokes mapping methods via public_send.
#
# Skipping nil keeps the key out of the output, which is what the default
# mapping does. `false` is a real literal, so the guard tests for nil
# rather than truthiness.
def value_to(model, doc)
literal = model.value
return if literal.nil?

doc["value"] = unwrap_literal(literal)
end

# lutaml-model runs this on a throwaway mapper instance, so `self` is not
# the object being deserialized - `model` is. Writing to `self.value`
# here would update an object that is discarded a moment later.
def value_from(model, value)
model.value = value
end

private

# A LiteralValue sits at the top level for a map literal, and inside the
# array for a list of references. Writing one through unwrapped raises
# ArgumentError in the adapter.
def unwrap_literal(value)
return value.map { |item| unwrap_literal(item) } if value.is_a?(::Array)

value.is_a?(LiteralValue) ? value.value : value
end
end
end
end
6 changes: 6 additions & 0 deletions spec/fixtures/mixed_lml/instances.lml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ instances {
}
}

collection "test_suite_2" {
includes [
"gaming_pc"
]
}

import {
xml "test_data/products.xml" {
map_to Product
Expand Down
94 changes: 94 additions & 0 deletions spec/lutaml/lml/cli_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# frozen_string_literal: true

require "spec_helper"
require "tmpdir"
require "lutaml/lml/cli"

# The gem writes YAML with Document#to_yaml and reads it back through the
# `-i yaml` input path. A document the gem produced itself has to survive that
# trip, so this drives the real Thor command rather than the parser underneath.
#
# `generate` is the command under test because it is the one that sets
# @input_format. `validate` never calls setup_options, so it cannot read any
# format at all - a separate pre-existing defect, not touched here.
RSpec.describe Lutaml::Cli::LmlCommands do
def write_yaml(fixture, dir)
doc = Lutaml::Lml::Parser.parse(File.new(fixtures_path(fixture)))
path = File.join(dir, "#{File.basename(fixture, '.lml')}.yaml")
File.write(path, doc.to_yaml)
path
end

def find_attribute(instance, name)
return nil unless instance

Array(instance.attributes).each do |attr|
return attr if attr.name == name

Array(attr.instances).each do |nested|
found = find_attribute(nested, name)
return found if found
end
end
find_attribute(instance.instance, name)
end

def generate(path, dir)
described_class.start(
["generate", "-i", "yaml", "-o", File.join(dir, "out.dot"), path],
)
end

# The defect is CollectionTrueMissingError raised out of the YAML reload,
# before anything is rendered. Reaching Graphviz therefore proves the reload
# worked, and Graphviz shells out to `dot`, which CI runners do not have.
# So a missing binary at the render step is a pass, and any other error
# still fails the example.
def reload_through_cli(path, dir)
generate(path, dir)
:rendered
rescue Errno::ENOENT => e
raise unless e.message.include?("dot")

:reached_renderer
end

describe "generate -i yaml" do
# data_s102_check.lml carries `prerequisites = [ "S102_Dev1009" ]`, a list
# literal. Before this change the reload raised CollectionTrueMissingError
# straight out of the command.
it "reads back YAML it wrote from a document with a list-valued attribute" do
Dir.mktmpdir do |dir|
path = write_yaml("lml/data_s102_check.lml", dir)

expect(%i[rendered reached_renderer])
.to include(reload_through_cli(path, dir))
end
end

it "reads back YAML it wrote from a document with a map-valued attribute" do
Dir.mktmpdir do |dir|
path = write_yaml("mixed_lml/instances.lml", dir)

expect(%i[rendered reached_renderer])
.to include(reload_through_cli(path, dir))
end
end

# The two examples above prove the command completes. They cannot prove the
# list came back, because these fixtures are instance data and the graphviz
# formatter only draws classes - the .dot it writes is empty boilerplate
# either way. So assert the values through the handler the command actually
# dispatches to for `-i yaml`, which is where the crash used to happen.
it "recovers the list values through the handler generate dispatches to" do
Dir.mktmpdir do |dir|
path = write_yaml("lml/data_s102_check.lml", dir)

doc = described_class::PARSE_HANDLERS["yaml"].call(Pathname.new(path))

expect(find_attribute(doc.instance, "prerequisites").value)
.to eq(["S102_Dev1009"])
end
end
end
end
23 changes: 23 additions & 0 deletions spec/lutaml/lml/data_processor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@
expect(result).to have_key(:instances)
expect(result[:instances].length).to eq(1)
end

it "returns an empty array for an empty attributes body" do
expect(processor.process_attributes_array([])).to eq([])
end
end

describe "#process_requires" do
Expand Down Expand Up @@ -227,6 +231,25 @@
end
end

describe "#process_instances" do
it "accumulates repeated collection, import, and export blocks" do
input = [
{ collections: { name: { string: "c1" }, includes: [{ string: "x" }] } },
{ collections: { name: { string: "c2" }, includes: [{ string: "y" }] } },
{ imports: [{ format_type: "xml", file: "a.xml" }] },
{ imports: [{ format_type: "csv", file: "b.csv" }] },
{ exports: [{ format_type: "xml" }] },
{ exports: [{ format_type: "step" }] }
]

result = processor.process_instances(input)

expect(result[:collections].map { |c| c[:name] }).to eq(%w[c1 c2])
expect(result[:imports].map { |i| i[:file] }).to eq(%w[a.xml b.csv])
expect(result[:exports].map { |e| e[:format_type] }).to eq(%w[xml step])
end
end

describe "ViewProcessing" do
describe "#process_show_list" do
it "extracts entity names from array of hashes" do
Expand Down
6 changes: 6 additions & 0 deletions spec/lutaml/lml/executor/condition_evaluator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,12 @@
expect(errors.first).to include("must reference i")
end

# Object.new specifically: the type guard has to run before the
# validations call, so the argument must not respond to `validations`.
it "returns no errors for an argument that is not a Collection" do
expect(described_class.evaluate(Object.new, [])).to eq([])
end

it "rescues NoMethodError on undefined instance attributes" do
collection = Lutaml::Lml::Collection.new(
name: "test",
Expand Down
Loading
Loading