Skip to content
Open
Show file tree
Hide file tree
Changes from 14 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
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
*/
package org.openhab.core.model.rule.runtime.internal;

import static org.openhab.core.model.core.ModelCoreConstants.isIsolatedModel;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
Expand Down Expand Up @@ -88,6 +90,7 @@
* No rule conditions are used as this concept does not exist for DSL rules.
*
* @author Kai Kreuzer - Initial contribution
* @author Laurent Garnier - Add optional rule UID + rules stored in a map per model
*/
@NonNullByDefault
@Component(immediate = true, service = { DSLRuleProvider.class, RuleProvider.class, DSLScriptContextProvider.class })
Expand All @@ -98,7 +101,7 @@ public class DSLRuleProvider

private final Logger logger = LoggerFactory.getLogger(DSLRuleProvider.class);
private final Collection<ProviderChangeListener<Rule>> listeners = new ArrayList<>();
private final Map<String, Rule> rules = new ConcurrentHashMap<>();
private final Map<String, List<Rule>> rulesMap = new ConcurrentHashMap<>();
private final Map<String, IEvaluationContext> contexts = new ConcurrentHashMap<>();
private final Map<String, XExpression> xExpressions = new ConcurrentHashMap<>();
private final ReadyMarker marker = new ReadyMarker("rules", "dslprovider");
Expand All @@ -122,7 +125,7 @@ protected void activate() {
@Deactivate
protected void deactivate() {
modelRepository.removeModelRepositoryChangeListener(this);
rules.clear();
rulesMap.clear();
contexts.clear();
xExpressions.clear();
}
Expand All @@ -133,82 +136,106 @@ public void addProviderChangeListener(ProviderChangeListener<Rule> listener) {
}

@Override
public Collection<Rule> getAll() {
return rules.values();
public void removeProviderChangeListener(ProviderChangeListener<Rule> listener) {
listeners.remove(listener);
}

@Override
public void removeProviderChangeListener(ProviderChangeListener<Rule> listener) {
listeners.remove(listener);
public Collection<Rule> getAll() {
// Ignore isolated models
return rulesMap.entrySet().stream().filter(e -> !isIsolatedModel(e.getKey()))
.flatMap(e -> e.getValue().stream()).toList();
}

/**
* Returns all rules originating from the given model name.
*
* @param modelFileName the full model file name, including ".rules" or ".script" extension
* @return the rules associated with the given model name, or an empty collection if none exist
*/
public Collection<Rule> getAllFromModel(String modelFileName) {
return List.copyOf(rulesMap.getOrDefault(modelFileName, List.of()));
}

@Override
public void modelChanged(String modelFileName, EventType type) {
String ruleModelType = modelFileName.substring(modelFileName.lastIndexOf(".") + 1);
List<Rule> oldRules;
List<Rule> newRules = new ArrayList<>();
if ("rules".equalsIgnoreCase(ruleModelType)) {
boolean isolated = isIsolatedModel(modelFileName);
String ruleModelName = modelFileName.substring(0, modelFileName.lastIndexOf("."));
List<ModelRulePair> modelRules = new ArrayList<>();
switch (type) {
case ADDED:
case MODIFIED:
EObject model = modelRepository.getModel(modelFileName);
int index = 1;
if (model instanceof RuleModel ruleModel) {
int index = 1;
for (org.openhab.core.model.rule.rules.Rule rule : ruleModel.getRules()) {
Rule newRule = toRule(ruleModelName, rule, index);
rules.put(newRule.getUID(), newRule);
xExpressions.put(ruleModelName + "-" + index, rule.getScript());
modelRules.add(new ModelRulePair(newRule, null));
newRules.add(toRule(ruleModelName, rule, index));
if (!isolated) {
xExpressions.put(ruleModelName + "-" + index, rule.getScript());
}
index++;
}
handleVarDeclarations(ruleModelName, ruleModel);
if (!isolated) {
handleVarDeclarations(ruleModelName, ruleModel);
}
}
break;
case MODIFIED:
removeRuleModel(ruleModelName);
EObject modifiedModel = modelRepository.getModel(modelFileName);
if (modifiedModel instanceof RuleModel ruleModel) {
int index = 1;
for (org.openhab.core.model.rule.rules.Rule rule : ruleModel.getRules()) {
Rule newRule = toRule(ruleModelName, rule, index);
Rule oldRule = rules.remove(ruleModelName);
rules.put(newRule.getUID(), newRule);
xExpressions.put(ruleModelName + "-" + index, rule.getScript());
modelRules.add(new ModelRulePair(newRule, oldRule));
oldRules = rulesMap.put(modelFileName, newRules);
if (!isolated) {
// Cleanup xExpressions for old rules
int nbOldRules = oldRules == null ? 0 : oldRules.size();
while (index <= nbOldRules) {
xExpressions.remove(ruleModelName + "-" + index);
index++;
}
handleVarDeclarations(ruleModelName, ruleModel);
// Cleanup contexts if no new rules
if (newRules.isEmpty()) {
contexts.remove(ruleModelName);
}
notifyProviderChangeListeners(calcChanges(modelFileName, oldRules, newRules));
}
break;
case REMOVED:
removeRuleModel(ruleModelName);
oldRules = rulesMap.remove(modelFileName);
if (!isolated) {
for (Iterator<Entry<String, XExpression>> it = xExpressions.entrySet().iterator(); it
.hasNext();) {
Entry<String, XExpression> entry = it.next();
if (belongsToModel(entry.getKey(), ruleModelName)) {
it.remove();
}
}
contexts.remove(ruleModelName);
notifyProviderChangeListeners(calcChanges(modelFileName, oldRules, null));
}
break;
default:
logger.debug("Unknown event type.");
}
notifyProviderChangeListeners(modelRules);
} else if ("script".equals(ruleModelType)) {
List<ModelRulePair> modelRules = new ArrayList<>();
switch (type) {
case MODIFIED:
case ADDED:
case MODIFIED:
EObject model = modelRepository.getModel(modelFileName);
if (model instanceof Script script) {
Rule oldRule = rules.remove(modelFileName);
Rule newRule = toRule(modelFileName, script);
rules.put(newRule.getUID(), newRule);
modelRules.add(new ModelRulePair(newRule, oldRule));
newRules.add(toRule(modelFileName, script));
}
oldRules = rulesMap.put(modelFileName, newRules);
if (!isIsolatedModel(modelFileName)) {
notifyProviderChangeListeners(calcChanges(modelFileName, oldRules, newRules));
}
break;
case REMOVED:
Rule oldRule = rules.remove(modelFileName);
if (oldRule != null) {
listeners.forEach(listener -> listener.removed(this, oldRule));
oldRules = rulesMap.remove(modelFileName);
if (!isIsolatedModel(modelFileName)) {
notifyProviderChangeListeners(calcChanges(modelFileName, oldRules, null));
}
break;
default:
logger.debug("Unknown event type.");
}
notifyProviderChangeListeners(modelRules);
}
}

Expand All @@ -227,24 +254,41 @@ private void handleVarDeclarations(String modelName, RuleModel ruleModel) {
contexts.put(modelName, context);
}

private void removeRuleModel(String modelName) {
Iterator<Entry<String, Rule>> it = rules.entrySet().iterator();
while (it.hasNext()) {
Entry<String, Rule> entry = it.next();
if (belongsToModel(entry.getKey(), modelName)) {
listeners.forEach(listener -> listener.removed(this, entry.getValue()));

it.remove();
}
private Changes calcChanges(String modelFileName, @Nullable List<Rule> oldRules, @Nullable List<Rule> newRules) {
if (oldRules == null || oldRules.isEmpty()) {
return new Changes(newRules == null ? List.of() : List.copyOf(newRules), List.of(), List.of());
}
Iterator<Entry<String, XExpression>> it2 = xExpressions.entrySet().iterator();
while (it2.hasNext()) {
Entry<String, XExpression> entry = it2.next();
if (belongsToModel(entry.getKey(), modelName)) {
it2.remove();
List<Rule> oldMutable = new ArrayList<>(oldRules);
List<Rule> newMutable = newRules == null ? new ArrayList<>() : new ArrayList<>(newRules);
List<RulePair> modified = new ArrayList<>();
Rule oldRule, newRule;
boolean found;
for (Iterator<Rule> iterator = oldMutable.iterator(); iterator.hasNext();) {
oldRule = iterator.next();
found = false;
String uid = oldRule.getUID();
for (Iterator<Rule> newIterator = newMutable.iterator(); newIterator.hasNext() && !found;) {
Comment on lines +283 to +287
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calcChanges uses a nested iteration (O(n^2)) to match rules by UID. With larger rule sets this can be unnecessarily expensive, and it becomes harder to reason about behavior if duplicate UIDs ever occur. Building a Map<uid, Rule> for one side would make matching O(n) and deterministic.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

@Nadahar Nadahar Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is a problem here. We're talking very low numbers in total here. Also, it's not quite as bad as it seems, because it removes matching entries, so the number of items to iterate keeps getting smaller. I'm pretty sure that this is many times more effective than using streams.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have done no change, following @Nadahar suggestion.

newRule = newIterator.next();
if (uid.equals(newRule.getUID())) {
modified.add(new RulePair(oldRule, newRule));
newIterator.remove();
found = true;
}
}
if (!found) {
// Check if the old rule exists in another model file with the same UID
Rule existingRule = rulesMap.entrySet().stream().filter(e -> !e.getKey().equals(modelFileName))
.flatMap(e -> e.getValue().stream()).filter(r -> uid.equals(r.getUID())).findAny().orElse(null);
if (existingRule != null) {
modified.add(new RulePair(oldRule, existingRule));
found = true;
}
}
if (found) {
iterator.remove();
}
}
contexts.remove(modelName);
return new Changes(newMutable, modified, oldMutable);
}

private boolean belongsToModel(String id, String modelName) {
Expand All @@ -270,7 +314,10 @@ private Rule toRule(String modelName, Script script) {

private Rule toRule(String modelName, org.openhab.core.model.rule.rules.Rule rule, int index) {
String name = rule.getName();
String uid = modelName + "-" + index;
String uid = rule.getUid();
if (uid == null || uid.isBlank()) {
uid = modelName + "-" + index;
Comment on lines +335 to +337
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

User-provided rule UIDs from the DSL are accepted verbatim. If a UID contains '/', it will be embedded into event topics like openhab/rules/{ruleID}/..., which breaks RuleEventFactory#getRuleId (expects exactly 4 topic segments) and can cause event parsing failures. Consider validating rule.getUid() (e.g., reject or fall back to the auto-generated UID when it contains '/' or other topic-delimiter characters) and log a clear warning when the configured UID is invalid.

Suggested change
String uid = rule.getUid();
if (uid == null || uid.isBlank()) {
uid = modelName + "-" + index;
String autoUid = modelName + "-" + index;
String uid = rule.getUid();
if (uid == null || uid.isBlank()) {
uid = autoUid;
} else if (uid.indexOf('/') >= 0) {
logger.warn(
"Ignoring invalid UID '{}' for rule '{}' in model '{}': character '/' is not allowed in rule UIDs. Using auto-generated UID '{}'.",
uid, name, modelName, autoUid);
uid = autoUid;

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is exactly the kind of thing I was thinking of when I referred to problematic characters that we don't yet know about. I'd say that it "proves" that #5467 is necessary, and that / must be excluded (this is assuming that copilot's claim is true, but I think I've seen code doing things like that).

}
Comment on lines 334 to +338
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

User-provided rule UIDs from the DSL are accepted verbatim (only blank/null falls back to auto-generated). Because rule UIDs are embedded directly into event topics (e.g., openhab/rules/{ruleID}/...), a UID containing '/' will produce an invalid topic that RuleEventFactory cannot parse (it expects exactly 4 topic segments). Add validation/sanitization for configured UIDs (at minimum reject '/' and fall back to the auto-generated UID, ideally with a warning log) to prevent runtime event failures.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a separate PR, #5467, that deals with this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

User-provided rule UIDs from the DSL are accepted verbatim (only blank/null falls back to auto-generated). Because rule UIDs are embedded directly into event topics (e.g., openhab/rules/{ruleID}/...), a UID containing '/' will produce an invalid topic that RuleEventFactory cannot parse (it expects exactly 4 topic segments). Add validation/sanitization for configured UIDs (at minimum reject '/' and fall back to the auto-generated UID, ideally with a warning log) to prevent runtime event failures.

I think this comment from copilot is the best reason to reject my wild idea. It does correctly point out the UID should be validated more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Nadahar : maybe it is good to exclude the / in that PR, even if there is a more general discussion in parallel in another PR?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lolodomo I have already modified #5467 so that both / and \ are excluded in all "suggested patterns". I just wished we could agree on something and move that PR forward.


// Create Triggers
triggerId = 0;
Expand Down Expand Up @@ -441,46 +488,60 @@ private String removeIndentation(String script) {

@Override
public void onReadyMarkerAdded(ReadyMarker readyMarker) {
for (String ruleFileName : modelRepository.getAllModelNamesOfType("rules")) {
EObject model = modelRepository.getModel(ruleFileName);
String ruleModelName = ruleFileName.substring(0, ruleFileName.indexOf("."));
for (String modelFileName : modelRepository.getAllModelNamesOfType("rules")) {
EObject model = modelRepository.getModel(modelFileName);
if (model instanceof RuleModel ruleModel) {
boolean isolated = isIsolatedModel(modelFileName);
String ruleModelName = modelFileName.substring(0, modelFileName.lastIndexOf("."));
int index = 1;
List<ModelRulePair> modelRules = new ArrayList<>();
List<Rule> newRules = new ArrayList<>();
for (org.openhab.core.model.rule.rules.Rule rule : ruleModel.getRules()) {
Rule newRule = toRule(ruleModelName, rule, index);
xExpressions.put(ruleModelName + "-" + index, rule.getScript());
modelRules.add(new ModelRulePair(newRule, null));
newRules.add(toRule(ruleModelName, rule, index));
if (!isolated) {
xExpressions.put(ruleModelName + "-" + index, rule.getScript());
}
index++;
}
handleVarDeclarations(ruleModelName, ruleModel);

notifyProviderChangeListeners(modelRules);
List<Rule> oldRules = rulesMap.put(modelFileName, newRules);
if (!isolated) {
handleVarDeclarations(ruleModelName, ruleModel);
notifyProviderChangeListeners(calcChanges(modelFileName, oldRules, newRules));
}
}
}
modelRepository.addModelRepositoryChangeListener(this);
readyService.markReady(marker);
}

private void notifyProviderChangeListeners(List<ModelRulePair> modelRules) {
modelRules.forEach(rulePair -> {
Rule oldRule = rulePair.oldRule();
if (oldRule != null) {
rules.remove(oldRule.getUID());
rules.put(rulePair.newRule().getUID(), rulePair.newRule());
listeners.forEach(listener -> listener.updated(this, oldRule, rulePair.newRule()));
} else {
rules.put(rulePair.newRule().getUID(), rulePair.newRule());
listeners.forEach(listener -> listener.added(this, rulePair.newRule()));
private void notifyProviderChangeListeners(Changes changes) {
if (listeners.isEmpty()) {
return;
}
for (Rule rule : changes.removed) {
for (ProviderChangeListener<Rule> listener : listeners) {
listener.removed(this, rule);
}
});
}
for (RulePair pair : changes.modified) {
for (ProviderChangeListener<Rule> listener : listeners) {
listener.updated(this, pair.oldRule, pair.newRule);
}
}
for (Rule rule : changes.added) {
for (ProviderChangeListener<Rule> listener : listeners) {
listener.added(this, rule);
}
}
}

@Override
public void onReadyMarkerRemoved(ReadyMarker readyMarker) {
readyService.unmarkReady(marker);
}

private record ModelRulePair(Rule newRule, @Nullable Rule oldRule) {
private record RulePair(Rule oldRule, Rule newRule) {
}

private record Changes(List<Rule> added, List<RulePair> modified, List<Rule> removed) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ VariableDeclaration:
;

Rule:
'rule' name=(STRING|ID) ('[' tags+=(ID|STRING) (',' tags+=(ID|STRING))* ']')?
'rule' name=(STRING|ID) (uid=(STRING|ID))? ('[' tags+=(ID|STRING) (',' tags+=(ID|STRING))* ']')?
'when' eventtrigger+=EventTrigger ('or' eventtrigger+=EventTrigger)*
'then' script=XExpressionInClosure
'end'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ public void testSimpleRules() {
Collection<Rule> rules = dslRuleProvider.getAll();
assertThat(rules.size(), is(0));

String model = "rule RuleNumberOne [ Test, \"my test\" ]\n" + //
String model = "rule RuleNumberOne rule1 [ Test, \"my test\" ]\n" + //
"when\n" + //
" System started\n" + //
"then\n" + //
Expand All @@ -117,7 +117,7 @@ public void testSimpleRules() {

Rule firstRule = it.next();

assertThat(firstRule.getUID(), is("dslruletest-1"));
assertThat(firstRule.getUID(), is("rule1"));
assertThat(firstRule.getName(), is("RuleNumberOne"));
assertThat(firstRule.getTags(), hasSize(2));
assertThat(firstRule.getTags(), hasItem("Test"));
Expand Down
Loading