Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion lib/lutaml/lml/models/top_element_attribute.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ 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: []
Expand Down
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
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
16 changes: 9 additions & 7 deletions spec/lutaml/lml/executor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,21 @@ def self.export(_exp, _instances, compiled:)
.to raise_error(Lutaml::Lml::Executor::FormatAdapter::AdapterNotFoundError)
end

it "collects validation errors from collection validations" do
collection = Lutaml::Lml::Collection.new(
name: "test",
validations: ["count >= 1"]
)
it "collects validation errors from every collection block" do
instances = Lutaml::Lml::InstanceCollection.new(
collections: collection
collections: [
Lutaml::Lml::Collection.new(name: "a", validations: ["count >= 1"]),
Lutaml::Lml::Collection.new(name: "b", validations: ["count >= 2"])
]
)
doc = Lutaml::Lml::Document.new(instances: instances)

result = described_class.run(doc, compiled: {})
expect(result.instances).to eq([])
expect(result.errors).to include(a_string_matching(/count >= 1/))
expect(result.errors).to include(
a_string_matching(/count >= 1/),
a_string_matching(/count >= 2/)
)
end
end
end
21 changes: 21 additions & 0 deletions spec/lutaml/lml/grammar_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,18 @@ class ValidationCheck {
expect(doc.requires).to include("deps.lml")
file.close!
end

it "parses single-quoted require statements" do
file = Tempfile.new(%w[test .lml])
file.write <<~LML
require 'deps.lml'
models Test { class Foo {} }
LML
file.rewind
doc = parser.parse(file)
expect(doc.requires).to include("deps.lml")
file.close!
end
end

describe "Grammar composition" do
Expand Down Expand Up @@ -282,6 +294,15 @@ class ValidationCheck {
file.close!
end

it "parses view with single-quoted import directive" do
file = Tempfile.new(%w[test .lutaml])
file.write("view MyView { import 'models/foo.lutaml' }")
file.rewind
doc = parser.parse(file)
expect(doc.view_imports.map(&:path)).to eq(["models/foo.lutaml"])
file.close!
end

it "parses view with multiple imports" do
file = Tempfile.new(%w[test .lutaml])
file.write("view MyView {\n import \"models/a.lutaml\"\n import \"models/b.lutaml\"\n}")
Expand Down
44 changes: 39 additions & 5 deletions spec/lutaml/lml/parser_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -235,12 +235,13 @@ def parse_lml(fname)
expect(doc.instances).to be_a(Lutaml::Lml::InstanceCollection)
end

it "maps collections correctly" do
it "maps every collection block, in order" do
collections = doc.instances.collections
expect(collections).to be_a(Lutaml::Lml::Collection)
expect(collections.name).to eq("test_suite_1")
expect(collections.includes).to eq(["laptop_123", "desktop_1", "desktop_2"])
expect(collections.validations).to eq(["count >= 3", "all? { |i| i.components.count > 0 }"])
expect(collections.map(&:name)).to eq(%w[test_suite_1 test_suite_2])
suite = collections.first
expect(suite.includes).to eq(["laptop_123", "desktop_1", "desktop_2"])
expect(suite.validations).to eq(["count >= 3", "all? { |i| i.components.count > 0 }"])
expect(collections.last.includes).to eq(["gaming_pc"])
end

it "maps imports correctly" do
Expand All @@ -256,6 +257,39 @@ def parse_lml(fname)
expect(csv_import.attributes.map(&:name)).to include("map_to", "columns")
end

it "preserves a map-valued attribute without dropping keys" do
csv_import = doc.instances.imports.find { |imp| imp.format_type == "csv" }
columns = csv_import.attributes.find { |a| a.name == "columns" }
expect(columns.value.value).to eq(
id: "component_id", type: "component_type", quantity: "count",
)
end

# JSON has no symbol type, so the keys come back as strings.
it "reloads a map-valued attribute through Document.from_json" do
reloaded = Lutaml::Lml::Document.from_json(doc.to_json)

csv_import = reloaded.instances.imports.find { |imp| imp.format_type == "csv" }
columns = csv_import.attributes.find { |a| a.name == "columns" }
expect(columns.value.value).to eq(
"id" => "component_id", "type" => "component_type", "quantity" => "count",
)
end

it "serializes a map-valued attribute to YAML without a ruby object tag" do
expect(doc.to_yaml).not_to include("!ruby/object")
end

it "reloads a map-valued attribute through Document.from_yaml" do
reloaded = Lutaml::Lml::Document.from_yaml(doc.to_yaml)

csv_import = reloaded.instances.imports.find { |imp| imp.format_type == "csv" }
columns = csv_import.attributes.find { |a| a.name == "columns" }
expect(columns.value.value).to eq(
id: "component_id", type: "component_type", quantity: "count",
)
end

it "maps exports correctly" do
exports = doc.instances.exports
expect(exports.size).to eq(2)
Expand Down
Loading