diff --git a/app/packages/language-model-transformation/src/features/diagram-server/modelTransformationLabelEditValidator.ts b/app/packages/language-model-transformation/src/features/diagram-server/modelTransformationLabelEditValidator.ts index 2f1eb543..9c3529bc 100644 --- a/app/packages/language-model-transformation/src/features/diagram-server/modelTransformationLabelEditValidator.ts +++ b/app/packages/language-model-transformation/src/features/diagram-server/modelTransformationLabelEditValidator.ts @@ -357,12 +357,13 @@ export class ModelTransformationLabelEditValidator extends BaseLabelEditValidato /** * Checks whether an operator is valid for the given modifier kind. * - * Only NONE (no modifier) and CREATE allow assignments (`=`). - * All other modifiers (DELETE, FORBID, REQUIRE) must use comparison (`==`). + * Only NONE (no modifier) and CREATE allow the assignment operator (`=`). + * All comparison operators (`==`, `!=`, `<`, `>`, `<=`, `>=`) are permitted + * for every modifier kind. * PatternObjectInstanceReference nodes (which have no modifier field) also - * only allow comparisons. + * only allow comparison operators. * - * @param operator The operator string (`=` or `==`) + * @param operator The operator string (`=`, `==`, `!=`, `<`, `>`, `<=`, or `>=`) * @param modifier The modifier kind of the enclosing instance, or 'reference' for references * @returns A validation status if the operator is not permitted, undefined otherwise */ @@ -373,7 +374,7 @@ export class ModelTransformationLabelEditValidator extends BaseLabelEditValidato if (modifier === "reference") { if (operator === "=") { return this.error( - "Assignment ('=') is not allowed in an instance reference. Use '==' for comparisons." + "Assignment ('=') is not allowed in an instance reference. Use a comparison operator (==, !=, <, >, <=, >=)." ); } return undefined; @@ -384,7 +385,7 @@ export class ModelTransformationLabelEditValidator extends BaseLabelEditValidato if (!allowed) { return this.error( `Assignment ('=') is not allowed with modifier '${modifier ?? "none"}'. ` + - `Only 'none' (no modifier) and 'create' instances support assignment. Use '==' for comparisons.` + `Only 'none' (no modifier) and 'create' instances support assignment. Use a comparison operator (==, !=, <, >, <=, >=).` ); } } diff --git a/app/packages/language-model-transformation/src/features/diagram-server/modelTransformationLabelParseUtils.ts b/app/packages/language-model-transformation/src/features/diagram-server/modelTransformationLabelParseUtils.ts index f1bcbc08..641450fc 100644 --- a/app/packages/language-model-transformation/src/features/diagram-server/modelTransformationLabelParseUtils.ts +++ b/app/packages/language-model-transformation/src/features/diagram-server/modelTransformationLabelParseUtils.ts @@ -3,6 +3,8 @@ * Used by both the apply-label-edit handler and the label-edit validator. */ +import type { PatternPropertyOperator } from "../../plugin/typedAst.js"; + /** * Parses an instance label in `name : type` format. * @@ -21,14 +23,20 @@ export function parseInstanceLabel(label: string): { name: string; type: string /** * Finds the index of the assignment `=` operator in a string, - * skipping over `==` comparison operators. + * skipping over multi-character operators (`==`, `!=`, `<=`, `>=`). * * @param text The text to search * @returns The index of the single `=`, or -1 if not found */ export function findAssignmentIndex(text: string): number { for (let i = 0; i < text.length; i++) { - if (text[i] === "=") { + const ch = text[i]; + // Skip `!=`, `<=`, `>=` — the `=` here belongs to the multi-char operator. + if ((ch === "!" || ch === "<" || ch === ">") && text[i + 1] === "=") { + i++; // skip the `=` that follows + continue; + } + if (ch === "=") { // Skip `==`. if (text[i + 1] === "=") { i++; @@ -65,38 +73,61 @@ export function parseVariableLabel(label: string): { name: string; value: string return name.length > 0 && value.length > 0 ? { name, value } : undefined; } +/** + * All multi-character comparison operators, checked longest-first so that + * `<=` is found before a bare `<`, etc. + */ +const MULTI_CHAR_OPERATORS: PatternPropertyOperator[] = ["==", "!=", "<=", ">="]; + +/** + * All single-character operators. + */ +const SINGLE_CHAR_OPERATORS: PatternPropertyOperator[] = ["<", ">", "="]; + /** * Parses a property assignment label into its three parts. * - * Supports both assignment (`=`) and comparison (`==`) operators. - * The first `=` occurrence determines operator type; `==` takes precedence. + * Supports all property operators: `=`, `==`, `!=`, `<`, `>`, `<=`, `>=`. + * Multi-character operators are checked before single-character ones to avoid + * prefix mismatches (e.g. `<=` found before `<`). * * @param label The raw label text to parse * @returns The parsed `{ name, operator, value }`, or an error message string if parsing fails */ export function parseModelTransformationPropertyLabel( label: string -): { name: string; operator: "=" | "=="; value: string } | string { - const eqIndex = label.indexOf("="); - if (eqIndex === -1) { - return "Missing '=' or '==' in property label."; +): { name: string; operator: PatternPropertyOperator; value: string } | string { + // Try multi-character operators first (longest-match). + for (const op of MULTI_CHAR_OPERATORS) { + const idx = label.indexOf(op); + if (idx === -1) continue; + const name = label.substring(0, idx).trim(); + const value = label.substring(idx + op.length).trim(); + if (name.length === 0) { + return "Missing property name before operator."; + } + if (value.length === 0) { + return "Missing value expression after operator."; + } + return { name, operator: op, value }; } - const isDouble = label[eqIndex + 1] === "="; - const operator: "=" | "==" = isDouble ? "==" : "="; - const operatorEnd = eqIndex + operator.length; - - const name = label.substring(0, eqIndex).trim(); - const value = label.substring(operatorEnd).trim(); - - if (name.length === 0) { - return "Missing property name before operator."; - } - if (value.length === 0) { - return "Missing value expression after operator."; + // Fall back to single-character operators. + for (const op of SINGLE_CHAR_OPERATORS) { + const idx = label.indexOf(op); + if (idx === -1) continue; + const name = label.substring(0, idx).trim(); + const value = label.substring(idx + op.length).trim(); + if (name.length === 0) { + return "Missing property name before operator."; + } + if (value.length === 0) { + return "Missing value expression after operator."; + } + return { name, operator: op, value }; } - return { name, operator, value }; + return "Missing operator in property label."; } /** diff --git a/app/packages/language-model-transformation/src/grammar/modelTransformationRules.ts b/app/packages/language-model-transformation/src/grammar/modelTransformationRules.ts index cb47797d..f2fd43cf 100644 --- a/app/packages/language-model-transformation/src/grammar/modelTransformationRules.ts +++ b/app/packages/language-model-transformation/src/grammar/modelTransformationRules.ts @@ -130,14 +130,25 @@ export function generateModelTransformationRules(): { /** * Pattern property assignment rule. - * Format: property = value or property == value + * Format: property op value, where op is one of: =, ==, !=, <, >, <=, >= + * The assignment operator (=) sets a property value; comparison operators + * (==, !=, <, >, <=, >=) constrain matching. Multi-character operators are + * listed before single-character ones to avoid prefix mismatches. * Uses expression for value to support complex expressions. */ const PatternPropertyAssignmentRule = createRule("PatternPropertyAssignmentRule") .returns(PatternPropertyAssignment) .as(({ set }) => [ set("name", ref(Property, ID)), - or(set("operator", "=="), set("operator", "=")), + or( + set("operator", "=="), + set("operator", "!="), + set("operator", "<="), + set("operator", ">="), + set("operator", "<"), + set("operator", ">"), + set("operator", "=") + ), set("value", ExpressionRule) ]); diff --git a/app/packages/language-model-transformation/src/plugin/typedAst.ts b/app/packages/language-model-transformation/src/plugin/typedAst.ts index 54dba14e..798012a2 100644 --- a/app/packages/language-model-transformation/src/plugin/typedAst.ts +++ b/app/packages/language-model-transformation/src/plugin/typedAst.ts @@ -54,6 +54,18 @@ export interface TypedPatternVariable { value: TypedExpression; } +/** + * Discriminated union of all operators that can appear in a + * PatternPropertyAssignment. + * + * - `"="` — assignment (sets the property when creating/modifying an instance). + * - `"=="` — equality comparison constraint. + * - `"!="` — inequality comparison constraint. + * - `"<"`, `">"`, `"<="`, `">="` — relational comparison constraints + * (only valid for comparable / numeric property types). + */ +export type PatternPropertyOperator = "=" | "==" | "!=" | "<" | ">" | "<=" | ">="; + /** * Property assignment in a pattern object instance. */ @@ -64,9 +76,10 @@ export interface TypedPatternPropertyAssignment { propertyName: string; /** - * The operator used (= for assignment, == for comparison). + * The operator used. + * `"="` is for assignment; all others are comparison constraints. */ - operator: string; + operator: PatternPropertyOperator; /** * The value expression. diff --git a/app/packages/service-model-transformation/src/handler/modelTransformationTypedAstConverter.ts b/app/packages/service-model-transformation/src/handler/modelTransformationTypedAstConverter.ts index 7cb19d71..374842fd 100644 --- a/app/packages/service-model-transformation/src/handler/modelTransformationTypedAstConverter.ts +++ b/app/packages/service-model-transformation/src/handler/modelTransformationTypedAstConverter.ts @@ -59,7 +59,8 @@ import { PatternObjectInstanceDelete, type PatternObjectInstanceDeleteType, type PatternObjectInstanceReferenceType, - PatternObjectInstanceReference + PatternObjectInstanceReference, + type PatternPropertyOperator } from "@mdeo/language-model-transformation"; import { AssociationEnd, type AssociationEndType } from "@mdeo/language-metamodel"; import type { AstNode } from "langium"; @@ -361,7 +362,7 @@ export class ModelTransformationTypedAstConverter extends TypedAstConverter { className: obj.class?.$refText ?? "", properties: obj.properties.map((prop) => ({ propertyName: prop.name?.$refText ?? "", - operator: prop.operator, + operator: prop.operator as PatternPropertyOperator, value: this.convertExpression(prop.value) })) }; @@ -380,7 +381,7 @@ export class ModelTransformationTypedAstConverter extends TypedAstConverter { name: objRef.instance.$refText, properties: objRef.properties.map((prop) => ({ propertyName: prop.name?.$refText ?? "", - operator: prop.operator, + operator: prop.operator as PatternPropertyOperator, value: this.convertExpression(prop.value) })) }; diff --git a/platform/model-transformation/src/main/kotlin/com/mdeo/modeltransformation/ast/patterns/PatternElements.kt b/platform/model-transformation/src/main/kotlin/com/mdeo/modeltransformation/ast/patterns/PatternElements.kt index cabec017..7c96a644 100644 --- a/platform/model-transformation/src/main/kotlin/com/mdeo/modeltransformation/ast/patterns/PatternElements.kt +++ b/platform/model-transformation/src/main/kotlin/com/mdeo/modeltransformation/ast/patterns/PatternElements.kt @@ -30,7 +30,12 @@ data class TypedPatternVariable( * (matching against a property value) within a pattern. * * @param propertyName Name of the property being assigned or compared. - * @param operator The operator used: "=" for assignment, "==" for comparison. + * @param operator The operator used: + * - `"="` — assignment (sets the property value). + * - `"=="` — equality comparison constraint. + * - `"!="` — inequality comparison constraint. + * - `"<"`, `">"`, `"<="`, `">="` — relational comparison constraints + * (only valid for comparable / numeric property types). * @param value The value expression for the assignment or comparison. */ @Serializable diff --git a/platform/model-transformation/src/main/kotlin/com/mdeo/modeltransformation/runtime/match/MatchTraversalBuilder.kt b/platform/model-transformation/src/main/kotlin/com/mdeo/modeltransformation/runtime/match/MatchTraversalBuilder.kt index c2fb33a6..0df67bb8 100644 --- a/platform/model-transformation/src/main/kotlin/com/mdeo/modeltransformation/runtime/match/MatchTraversalBuilder.kt +++ b/platform/model-transformation/src/main/kotlin/com/mdeo/modeltransformation/runtime/match/MatchTraversalBuilder.kt @@ -119,9 +119,9 @@ internal class MatchTraversalBuilder( /** * Applies a [BaseStep.InlinePropertyConstraint] to [t]. * - * For constant values emits `.has(key, value)`. For non-constant expressions emits a - * `.filter(equalityExpr.is(true))` sub-traversal that evaluates [step]'s expression - * against the anchor's current state. + * For constant-equality (`==`) constraints emits the cheap `.has(key, value)` step. + * For all other comparison operators (`!=`, `<`, `>`, `<=`, `>=`) and for non-constant + * equality expressions, emits a `.filter(comparisonExpr.is(true))` sub-traversal. * * @param t The traversal to extend. * @param step The inline property-constraint step to apply. @@ -134,8 +134,10 @@ internal class MatchTraversalBuilder( ): GraphTraversal { val graphKey = engine.resolvePropertyGraphKey(step.className, step.property.propertyName) val compiled = expressionSupport.compilePropertyExpression(step.property.value, emptyList()) + val operator = step.property.operator - return if (step.isConstant && compiled is CompilationResult.ValueResult) { + // The cheap `.has(key, value)` shortcut is only correct for equality (==). + return if (operator == "==" && step.isConstant && compiled is CompilationResult.ValueResult) { t.has(graphKey, compiled.value) as GraphTraversal } else if (compiled != null) { val propertyTraversal = AnonymousTraversal.values(graphKey) as GraphTraversal @@ -145,14 +147,10 @@ internal class MatchTraversalBuilder( ) as GraphTraversal val propertyType = expressionSupport.resolveExpressionType(step.property.value) ?: throw IllegalStateException("Cannot resolve type for: ${step.property.propertyName}") - val eq = EqualityCompilerUtil.buildEqualityTraversal( - "==", propertyTraversal, exprTraversal, - propertyType, propertyType, - engine.typeRegistry, - compilationContext.getUniqueId(), - compilationContext.getUniqueId() + val comparison = buildPropertyComparisonTraversal( + operator, propertyTraversal, exprTraversal, propertyType ) - t.filter(eq.`is`(true)) as GraphTraversal + t.filter(comparison.`is`(true)) as GraphTraversal } else { t } @@ -458,9 +456,9 @@ internal class MatchTraversalBuilder( * Applies a [BaseStep.DeferredPropertyConstraint] to [t]. * * Navigates to the instance via `select(instanceLabel)` and emits a - * `.where(has / equalityFilter)` sub-traversal that does not change the traverser - * position. Handles both constant and expression values, and both scalar and - * collection types. + * `.where(has / comparisonFilter)` sub-traversal that does not change the traverser + * position. Handles constant and expression values, and both scalar and + * collection types. Supports all comparison operators. * * @param t The traversal to extend. * @param step The deferred property-constraint step to apply. @@ -475,9 +473,11 @@ internal class MatchTraversalBuilder( val graphKey = engine.resolvePropertyGraphKey(step.className, step.property.propertyName) val compiled = expressionSupport.compilePropertyExpression(step.property.value, emptyList()) val propertyType = expressionSupport.resolveExpressionType(step.property.value) + val operator = step.property.operator return when { - compiled is CompilationResult.ValueResult && !expressionSupport.isCollectionType(propertyType) -> { + // The cheap `.has(key, value)` shortcut is only correct for equality (==). + operator == "==" && compiled is CompilationResult.ValueResult && !expressionSupport.isCollectionType(propertyType) -> { t.where( AnonymousTraversal.select(instanceLabel) .has(graphKey, compiled.value) @@ -490,14 +490,10 @@ internal class MatchTraversalBuilder( val resolvedType = propertyType ?: throw IllegalStateException( "Cannot resolve type for: ${step.property.propertyName}" ) - val eq = EqualityCompilerUtil.buildEqualityTraversal( - "==", propTraversal, exprTraversal, - resolvedType, resolvedType, - engine.typeRegistry, - compilationContext.getUniqueId(), - compilationContext.getUniqueId() + val comparison = buildPropertyComparisonTraversal( + operator, propTraversal, exprTraversal, resolvedType ) - t.where(eq.`is`(true)) as GraphTraversal + t.where(comparison.`is`(true)) as GraphTraversal } compiled != null -> { val propTraversal = AnonymousTraversal.select(instanceLabel) @@ -509,14 +505,10 @@ internal class MatchTraversalBuilder( val resolvedType = propertyType ?: throw IllegalStateException( "Cannot resolve type for: ${step.property.propertyName}" ) - val eq = EqualityCompilerUtil.buildEqualityTraversal( - "==", propTraversal, exprTraversal, - resolvedType, resolvedType, - engine.typeRegistry, - compilationContext.getUniqueId(), - compilationContext.getUniqueId() + val comparison = buildPropertyComparisonTraversal( + operator, propTraversal, exprTraversal, resolvedType ) - t.where(eq.`is`(true)) as GraphTraversal + t.where(comparison.`is`(true)) as GraphTraversal } else -> t } @@ -578,4 +570,63 @@ internal class MatchTraversalBuilder( hasLabel(className) as GraphTraversal } } + + /** + * Builds a boolean-producing Gremlin traversal for a property comparison constraint. + * + * Dispatches based on [operator]: + * - `"=="` and `"!="` — delegates to [EqualityCompilerUtil.buildEqualityTraversal], which + * handles collection folding and enum-type guards. + * - `"<"`, `">"`, `"<="`, `">="` — builds a `project().by(left).by(right).choose(where(left, P.*), true, false)` + * traversal using the corresponding Gremlin [P] predicate. + * + * The returned traversal produces `true` when the constraint is satisfied and `false` + * otherwise. Callers wrap it in `.filter(t.is(true))` or `.where(t.is(true))`. + * + * @param operator One of `"=="`, `"!="`, `"<"`, `">"`, `"<="`, `">="`. + * @param propertyTraversal A traversal that produces the property value from the vertex. + * @param exprTraversal A traversal that produces the compared expression value. + * @param type The resolved [com.mdeo.expression.ast.types.ValueType] of the property. + * @return A traversal producing a [Boolean] comparison result. + */ + @Suppress("UNCHECKED_CAST") + private fun buildPropertyComparisonTraversal( + operator: String, + propertyTraversal: GraphTraversal, + exprTraversal: GraphTraversal, + type: com.mdeo.expression.ast.types.ValueType + ): GraphTraversal { + return when (operator) { + "==", "!=" -> EqualityCompilerUtil.buildEqualityTraversal( + operator, propertyTraversal, exprTraversal, + type, type, + engine.typeRegistry, + compilationContext.getUniqueId(), + compilationContext.getUniqueId() + ) + "<", ">", "<=", ">=" -> { + val leftLabel = compilationContext.getUniqueId() + val rightLabel = compilationContext.getUniqueId() + val predicate: P = when (operator) { + "<" -> P.lt(rightLabel) + ">" -> P.gt(rightLabel) + "<=" -> P.lte(rightLabel) + ">=" -> P.gte(rightLabel) + else -> throw IllegalStateException("Unexpected relational operator: $operator") + } + AnonymousTraversal.project(leftLabel, rightLabel) + .by(propertyTraversal) + .by(exprTraversal) + .choose( + AnonymousTraversal.where(leftLabel, predicate), + AnonymousTraversal.constant(true), + AnonymousTraversal.constant(false) + ) as GraphTraversal + } + else -> throw IllegalStateException( + "Unsupported property comparison operator: '$operator'. " + + "Expected one of: ==, !=, <, >, <=, >=" + ) + } + } } diff --git a/platform/model-transformation/src/main/kotlin/com/mdeo/modeltransformation/runtime/match/plan/MatchPlanBuilder.kt b/platform/model-transformation/src/main/kotlin/com/mdeo/modeltransformation/runtime/match/plan/MatchPlanBuilder.kt index 303f3b12..eaed5396 100644 --- a/platform/model-transformation/src/main/kotlin/com/mdeo/modeltransformation/runtime/match/plan/MatchPlanBuilder.kt +++ b/platform/model-transformation/src/main/kotlin/com/mdeo/modeltransformation/runtime/match/plan/MatchPlanBuilder.kt @@ -696,14 +696,15 @@ internal class MatchPlanBuilder( * covered instances. They are revisited by [tryInlineDeferredProperties] after * every new coverage event. * - * Only `==` (equality) properties are handled; properties with other operators - * are silently ignored. + * All comparison operators (`==`, `!=`, `<`, `>`, `<=`, `>=`) produce inline + * or deferred constraints. The assignment operator (`=`) is skipped — it is + * handled by [GraphModificationApplier], not by the match plan. * * @param instance The instance element whose properties are to be processed. */ private fun addInlinePropertyConstraints(instance: TypedPatternObjectInstanceElement) { for (property in instance.objectInstance.properties) { - if (property.operator != "==") continue + if (property.operator == "=") continue val referencedNodes = graph.nodeAnalyzer.findReferencedNodes(property.value) val referencedVars = referencedNodes.filter { it in graph.variableNames } val isConstant = referencedNodes.isEmpty() && !graph.isCollectionExpression(property.value) @@ -880,9 +881,11 @@ internal class MatchPlanBuilder( * Returns the inline property constraint steps for [instance] inside an * application condition. * - * Only `==` properties are included. The - * [BaseStep.InlinePropertyConstraint.isConstant] flag is determined the same way - * as in [addInlinePropertyConstraints]. Variable-referencing and collection + * All comparison operators (`==`, `!=`, `<`, `>`, `<=`, `>=`) are included. + * The assignment operator (`=`) is skipped — it is handled by the modification + * applier, not by the match plan. + * The [BaseStep.InlinePropertyConstraint.isConstant] flag is determined the same + * way as in [addInlinePropertyConstraints]. Variable-referencing and collection * expressions are included unconditionally because they are emitted inside a * `where(...)` block where the outer traversal state is already fixed. * @@ -892,7 +895,7 @@ internal class MatchPlanBuilder( private fun buildConditionPropertySteps( instance: TypedPatternObjectInstanceElement ): List = instance.objectInstance.properties.mapNotNull { property -> - if (property.operator != "==") return@mapNotNull null + if (property.operator == "=") return@mapNotNull null val referencedNodes = graph.nodeAnalyzer.findReferencedNodes(property.value) val isConstant = referencedNodes.isEmpty() && !graph.isCollectionExpression(property.value) BaseStep.InlinePropertyConstraint(instance.objectInstance.name, instance.objectInstance.className, property, isConstant)