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
44 changes: 34 additions & 10 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,29 @@ Logical operators that can conjoin conditions:
* Logical: `&&`, `||`, `!`
* List membership: `in`

Operator precedence, from tightest to loosest: `!`, then the comparison,
membership and existence predicates (`=`, `!=`, `>`, `<`, `>=`, `<=`, `in`,
`exists`), then `&&`, then `||`. Use parentheses to override it.

So `a in ('x','y') && b = 'z'` groups as `(a in ('x','y')) && (b = 'z')`, and
`a = 'x' || b = 'y' && c = 'z'` groups as `(a = 'x') || ((b = 'y') && (c = 'z'))`.

`and` is accepted as an alias for `&&`.

Comparison values are single-quoted strings (`'author'`) or bare numbers
(`30`). Use `\'` for a literal quote inside a value.

Within a segment, `[` opens a filter only when all three hold:

. it is not the segment's first character;
. the bracket group ends the segment; and
. its body contains a condition operator.

Otherwise `[` is an ordinary wildcard character set. So `Class[A-Z]` remains a
pattern because its body has no operator, and `[exists]` remains a pattern
because its `[` is the segment's first character, while `contributor[exists]`
is a filter.


[example]
====
Expand Down Expand Up @@ -594,7 +617,9 @@ contributor[role.type = 'author' and organization.type = 'standards']

The LutaML Path gem provides a simple API for parsing and matching paths.

WARNING: It currently only supports the model definition path syntax.
WARNING: Instance data paths are parsed but not resolved. `Lutaml::Path.parse`
returns an `InstancePath` carrying the parsed structure; evaluating one against
model instance data is not yet supported.

=== How to install

Expand Down Expand Up @@ -631,16 +656,15 @@ path = Lutaml::Path.parse("::Root::Package::Class")
path = Lutaml::Path.parse("Package::*::BaseClass*")
----

// TODO: enable
// [source,ruby]
// ----
// # Model instance data path
// ## Parse model data path
// path = Lutaml::Path.parse("obj.contributor.organization.name")
[source,ruby]
----
# Model instance data path
## Parse model data path
path = Lutaml::Path.parse("obj.contributor.organization.name")

// ## Parse model data path with filter
// path = Lutaml::Path.parse("obj.contributor[role.type='publisher']")
// ----
## Parse model data path with filter
path = Lutaml::Path.parse("obj.contributor[role.type='publisher']")
----

=== Working with patterns

Expand Down
17 changes: 14 additions & 3 deletions lib/lutaml/path.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,31 @@

require "parslet"
require_relative "path/version"
require_relative "path/errors"
require_relative "path/parser"
require_relative "path/transformer"
require_relative "path/element_path"
require_relative "path/path_segment"
require_relative "path/condition"
require_relative "path/step"
# abstract_path must precede element_path: `class ElementPath < AbstractPath`
# resolves the constant at class-definition time.
require_relative "path/abstract_path"
require_relative "path/element_path"
require_relative "path/instance_path"

module Lutaml
module Path
class ParseError < StandardError; end

def self.parse(input)
tree = Parser.new.parse(input)
Transformer.new.apply(tree)
rescue Parslet::ParseFailed => e
raise ParseError, e.message
rescue SystemStackError
# The condition grammar recurses through group/not/and/or, so an input
# nested deeply enough (~200 parentheses) exhausts the stack. That is a
# property of the input, not a bug in the caller: parse must only ever
# raise ParseError.
raise ParseError, "expression nests too deeply to parse"
end
end
end
24 changes: 24 additions & 0 deletions lib/lutaml/path/abstract_path.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

module Lutaml
module Path
# Common supertype for parsed paths. Holds only whether the path is
# absolute.
#
# It deliberately declares neither match? nor segments: InstancePath
# cannot honour match? under parse-only, and the two subclasses hold
# genuinely different element types (PathSegment answers match?; Step,
# which can carry a filter, must not).
class AbstractPath
attr_reader :absolute

def initialize(absolute: false)
@absolute = absolute
end

def absolute?
@absolute
end
end
end
end
100 changes: 100 additions & 0 deletions lib/lutaml/path/condition.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# frozen_string_literal: true

require_relative "path_segment"

module Lutaml
module Path
# The filter-condition AST.
#
# Parse-only: these nodes carry structure and render themselves, and
# evaluate nothing. They are Structs because there is no behaviour to
# justify hand-written classes, and Struct supplies value equality for
# free. Data.define would also freeze them, but it is Ruby 3.2+ and this
# gem supports 3.0.
#
# Every node freezes itself, along with any collection or string it owns:
# a parsed AST must be immutable, and Struct otherwise hands out public
# setters. Freezing the Struct alone is shallow -- `names << "x"` would
# still mutate, and would silently invalidate the Struct's own hash.
module Condition
# A dotted left-hand side: "role.type" -> ["role", "type"].
AttributeRef = Struct.new(:names) do
def initialize(*) = super.tap { names.each(&:freeze).freeze and freeze }

# Re-escapes BOTH the dot and the backslash the transformer unescaped.
# Escaping only the dot is incomplete: a name ending in a backslash
# would render as "a\.b" and re-parse as the single name "a.b".
def to_s
names.map { |n| n.gsub(/([\\.])/) { "\\#{::Regexp.last_match(1)}" } }.join(".")
end
end

# A right-hand literal. `source` is the unescaped lexeme and is never
# coerced: pattern? must read it directly so a numeric never reaches
# String#match?.
Value = Struct.new(:source, :type) do
def initialize(*) = super.tap { source.freeze and freeze }

def pattern?
type == :string && source.match?(PathSegment::GLOB_CHARS)
end

# Re-escapes BOTH the quote and the backslash the transformer
# unescaped. Escaping only the quote is incomplete: a value ending in a
# backslash would render as 'abc\' and its trailing escape would eat
# the closing quote.
def to_s
return source if type == :number

"'#{source.gsub(/(['\\])/) { "\\#{::Regexp.last_match(1)}" }}'"
end
end

Comparison = Struct.new(:lhs, :op, :rhs) do
def initialize(*) = super.tap { op.freeze and freeze }

def to_s = "#{lhs}#{op}#{rhs}"
end

# `literals`, not `values`: a member named `values` would shadow
# Struct#values, which returns the member list.
Membership = Struct.new(:lhs, :literals) do
def initialize(*) = super.tap { literals.freeze and freeze }

def to_s = "#{lhs} in (#{literals.join(",")})"
end

# `exists` carries no operand, so there is no member to declare. Written
# as a plain class rather than a zero-member Struct, which Ruby permits
# only from 3.3 while this gem supports >= 3.0.
class Existence
def initialize = freeze

def to_s = "exists"

# instance_of?, not is_a?: exact-class equality is what the Struct gave,
# and hash is keyed on the class, so is_a? would make a subclass compare
# asymmetrically against a differing hash.
def ==(other) = other.instance_of?(self.class)
alias eql? ==

def hash = self.class.hash
end

# && and || are structurally identical, so one type carries both.
# to_s ALWAYS parenthesises: parse(p.to_s) == p asserts TREE equality,
# and explicit parens make re-association impossible on a re-parse.
BinaryOperation = Struct.new(:operator, :left, :right) do
def initialize(*) = super.tap { operator.freeze and freeze }

def to_s = "(#{left} #{operator} #{right})"
end

Negation = Struct.new(:operand) do
def initialize(*) = super.tap { freeze }

def to_s = "!#{operand}"
end
end
end
end
23 changes: 18 additions & 5 deletions lib/lutaml/path/element_path.rb
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
# frozen_string_literal: true

require_relative "abstract_path"

module Lutaml
module Path
class ElementPath
attr_reader :segments, :absolute
class ElementPath < AbstractPath
attr_reader :segments

def initialize(segments, absolute: false)
super(absolute: absolute)
@segments = Array(segments)
@absolute = absolute
end

def absolute?
@absolute
def to_s
"#{absolute? ? "::" : ""}#{segments.join("::")}"
end

def ==(other)
other.instance_of?(self.class) &&
absolute? == other.absolute? &&
segments == other.segments
end
alias eql? ==

def hash
[self.class, absolute?, segments].hash
end

def match?(path_segments)
Expand Down
12 changes: 12 additions & 0 deletions lib/lutaml/path/errors.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

module Lutaml
module Path
class ParseError < StandardError; end

# Raised when a path parses but the operation asked of it is not
# implemented. A StandardError, unlike Ruby's NotImplementedError, which
# descends from ScriptError and so escapes `rescue => e`.
class ResolutionError < StandardError; end
end
end
55 changes: 55 additions & 0 deletions lib/lutaml/path/instance_path.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true

require_relative "abstract_path"
require_relative "errors"

module Lutaml
module Path
# A parsed instance-data path: a base (the "::" anchor) plus dot-separated
# attribute navigation, with optional filter conditions on any step.
#
# Parse-only. It carries structure and renders itself; resolving it against
# real model data is out of scope, so match? raises rather than lying by
# matching on names and silently ignoring the conditions.
class InstancePath < AbstractPath
attr_reader :base_steps, :attribute_steps

def initialize(base_steps:, attribute_steps: [], absolute: false)
super(absolute: absolute)
@base_steps = Array(base_steps)
@attribute_steps = Array(attribute_steps)
end

def steps
base_steps + attribute_steps
end

def conditions
steps.filter_map(&:condition)
end

def match?(_path_segments)
raise ResolutionError,
"instance paths are parsed but not resolved: #{self}. " \
"If you meant a literal dot in an element name, escape it: a\\.b"
end

def to_s
"#{absolute? ? "::" : ""}#{base_steps.join("::")}" \
"#{attribute_steps.map { |s| ".#{s}" }.join}"
end

def ==(other)
other.instance_of?(self.class) &&
absolute? == other.absolute? &&
base_steps == other.base_steps &&
attribute_steps == other.attribute_steps
end
alias eql? ==

def hash
[self.class, absolute?, base_steps, attribute_steps].hash
end
end
end
end
Loading
Loading