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
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,11 @@ public ResponseEntity<?> generate(@RequestParam("workspace") String workspace, @
try {
IntentGenerationService.GenerationResult result =
generationService.generate(yaml, projectObject.getPath(), project, workspace, baseName(path));
// "warnings" carries non-fatal dropped glue (e.g. an unresolvable notify recipient): the
// generation succeeded, but the caller must be able to see what was NOT emitted rather than
// it living only in a server log line (dirigible #6360).
return ResponseEntity.ok(Map.of("workspace", workspace, "project", project, "written", result.written(), "scrubbed",
result.scrubbed(), "codeGenerations", result.codeGenerations()));
result.scrubbed(), "codeGenerations", result.codeGenerations(), "warnings", result.issues()));
} catch (IntentValidationException e) {
return ResponseEntity.unprocessableEntity()
.body(Map.of("issues", e.getIssues()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ public void generate(IntentGenerationContext context) {
List<Map<String, Object>> aborts = buildAborts(model, settings);
List<Map<String, Object>> writers = buildWriters(model, settings);
List<Map<String, Object>> setters = buildSetters(model, settings);
List<Map<String, Object>> notifications = buildNotifications(model, byName, compositionParents, settings);
List<Map<String, Object>> notifications = buildNotifications(model, byName, compositionParents, settings, context);
List<Map<String, Object>> schedules = buildSchedules(model, byName, compositionParents, settings, context);
List<Map<String, Object>> integrations = buildIntegrations(model, byName, compositionParents, settings);
List<Map<String, Object>> inbound = buildInbound(model, byName, compositionParents, settings);
Expand Down Expand Up @@ -353,8 +353,9 @@ private static UsesIntent findUses(IntentModel model, String alias) {
}

private static List<Map<String, Object>> buildNotifications(IntentModel model, Map<String, EntityIntent> byName,
Map<String, String> compositionParents, IntentSettings settings) {
Map<String, String> compositionParents, IntentSettings settings, IntentGenerationContext context) {
List<Map<String, Object>> notifications = new ArrayList<>();
NotificationSupport.CrossModelLookup lookup = crossModelLookup(model, context);
for (NotificationIntent notification : model.getNotifications()) {
if (notification.getName() == null || notification.getName()
.isBlank()) {
Expand All @@ -368,10 +369,10 @@ private static List<Map<String, Object>> buildNotifications(IntentModel model, M
LOGGER.info("Settings opt-out: keeping existing listener for notification [{}] (not generated)", notification.getName());
continue;
}
NotificationSupport.Plan plan = NotificationSupport.plan(notification, byName.get(entity), byName, compositionParents);
NotificationSupport.Plan plan = NotificationSupport.plan(notification, byName.get(entity), byName, compositionParents, lookup);
if (plan == null) {
LOGGER.warn("Notification [{}] recipient [{}] is not a resolvable field or relation.field of [{}] - skipping",
notification.getName(), notification.getTo(), entity);
reportDroppedGlue(context, "Notification [" + notification.getName() + "] recipient [" + notification.getTo()
+ "] is not a resolvable field or relation.field of [" + entity + "] - the notification was NOT generated");
continue;
}
Map<String, Object> entry = new LinkedHashMap<>();
Expand Down Expand Up @@ -1502,13 +1503,12 @@ private static List<Map<String, Object>> buildSchedules(IntentModel model, Map<S
}
} else {
// The per-row action reuses the notification machinery against the queried row entity.
NotificationSupport.Plan plan =
NotificationSupport.plan(schedule.getNotify(), byName.get(entity), byName, compositionParents);
NotificationSupport.Plan plan = NotificationSupport.plan(schedule.getNotify(), byName.get(entity), byName,
compositionParents, crossModelLookup(model, context));
if (plan == null) {
LOGGER.warn("Schedule [{}] notify recipient [{}] is not a resolvable field or relation.field of [{}] - skipping",
schedule.getName(), schedule.getNotify()
.getTo(),
entity);
reportDroppedGlue(context, "Schedule [" + schedule.getName() + "] notify recipient [" + schedule.getNotify()
.getTo()
+ "] is not a resolvable field or relation.field of [" + entity + "] - the schedule was NOT generated");
continue;
}
entry.put("action", "notify");
Expand All @@ -1532,6 +1532,42 @@ static List<Map<String, Object>> buildSchedulesForTest(IntentModel model) {
null);
}

/**
* A cross-model relation resolver for the notify machinery: reads the owner model's facts
* (perspective / project / property names) through {@link CrossModelSupport} so a
* {@code relation.field} recipient or placeholder can point at an entity owned by another model.
* Returns {@code null} for a relation whose {@code uses} alias is not declared; a declared-but-
* unresolvable owner fails loudly through {@code CrossModelSupport.resolve} (the same "generate the
* leaf first" contract as generate targets and relation links). A {@code null} context (unit test)
* yields a no-op lookup so same-model resolution is unaffected.
*/
private static NotificationSupport.CrossModelLookup crossModelLookup(IntentModel model, IntentGenerationContext context) {
if (context == null) {
return relation -> null;
}
return relation -> {
UsesIntent uses = findUses(model, relation.getModel());
if (uses == null) {
return null;
}
CrossModelSupport.TargetInfo target = CrossModelSupport.resolve(context, uses, relation.getTo());
return new NotificationSupport.CrossModelTarget(target.perspectiveName(), uses.resolveProject(), uses.getModel(),
target.propertyNames());
};
}

/**
* Surface a piece of glue that could not be emitted (e.g. an unresolvable notify recipient) both in
* the log AND as a generate-response issue, so the drop is not silent at the API level (dirigible
* #6360). The generation itself still succeeds - the issue is a warning, not a 422.
*/
private static void reportDroppedGlue(IntentGenerationContext context, String message) {
LOGGER.warn(message);
if (context != null) {
context.addIssue(message);
}
}

private static List<Map<String, Object>> relationLoads(NotificationSupport.Plan plan) {
List<Map<String, Object>> loads = new ArrayList<>();
for (NotificationSupport.RelationLoad load : plan.loads()) {
Expand All @@ -1540,6 +1576,11 @@ private static List<Map<String, Object>> relationLoads(NotificationSupport.Plan
entry.put("targetEntity", load.targetEntity());
entry.put("targetPerspective", load.targetPerspective());
entry.put("fkProperty", load.fkProperty());
// Cross-model recipient/placeholder: the owner's model alias + project drive the OWNER-package
// import in the generated listener/job (generateUtils picks the gen folder from these).
entry.put("crossModel", load.crossModel());
entry.put("targetModel", load.targetModel());
entry.put("targetProject", load.targetProject());
loads.add(entry);
}
return loads;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ public final class IntentGenerationContext {
/** Bare file names written under {@link #projectRoot} during this generation pass. */
private final Set<String> writtenFileNames = new LinkedHashSet<>();

/**
* Non-fatal generation issues (e.g. a piece of glue that could not be emitted because a reference
* did not resolve) collected during the pass. Surfaced in the generate response so the drop is not
* silent at the API level (dirigible #6360) - the generation still succeeds.
*/
private final java.util.List<String> issues = new java.util.ArrayList<>();

IntentGenerationContext(IntentModel model, String projectRoot, String projectName, String workspaceName, String fallbackName,
IRepository repository) {
this.model = model;
Expand Down Expand Up @@ -124,6 +131,24 @@ public Set<String> getWrittenFileNames() {
return Collections.unmodifiableSet(writtenFileNames);
}

/**
* Record a non-fatal generation issue (dropped glue). Surfaced in the generate response.
*
* @param issue a human-readable description of what was not generated and why
*/
public void addIssue(String issue) {
issues.add(issue);
}

/**
* The non-fatal issues collected during this pass.
*
* @return an unmodifiable view of the issues
*/
public java.util.List<String> getIssues() {
return Collections.unmodifiableList(issues);
}

public String getProjectName() {
return projectName;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,11 @@ public IntentGenerationService(List<IntentTargetGenerator> generators, IReposito
* @param scrubbed bare names of previously generated files removed because the intent no longer
* declares their slice
* @param codeGenerations ordered plan entries, each {@code {path, templateId, parameters}}
* @param issues non-fatal generation issues (glue that could not be emitted) - the pass still
* succeeded, but a caller should surface these so the drop is not silent (dirigible #6360)
*/
public record GenerationResult(List<String> written, List<String> scrubbed, List<Map<String, Object>> codeGenerations) {
public record GenerationResult(List<String> written, List<String> scrubbed, List<Map<String, Object>> codeGenerations,
List<String> issues) {
}

/**
Expand Down Expand Up @@ -114,7 +117,7 @@ public GenerationResult generate(String yaml, String projectRoot, String project
}
List<String> scrubbed = scrubStaleModelFiles(projectRoot, context.getWrittenFileNames());
List<Map<String, Object>> plan = buildCodeGenerationPlan(context.getSettings(), context.getWrittenFileNames());
return new GenerationResult(new ArrayList<>(context.getWrittenFileNames()), scrubbed, plan);
return new GenerationResult(new ArrayList<>(context.getWrittenFileNames()), scrubbed, plan, context.getIssues());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,36 @@ public final class NotificationSupport {

private NotificationSupport() {}

/** A one-hop to-one relation the listener must load before building the message. */
public record RelationLoad(String local, String targetEntity, String targetPerspective, String fkProperty) {
/**
* A one-hop to-one relation the listener must load before building the message. A cross-model
* relation ({@code crossModel} true) points at an entity owned by another model, so the generated
* listener imports the OWNER's {@code targetModel}/{@code targetProject} generated
* Entity/Repository (the same registry-wide-compile mechanism the relation links / personal
* assignee use), not this project's.
*/
public record RelationLoad(String local, String targetEntity, String targetPerspective, String fkProperty, boolean crossModel,
String targetModel, String targetProject) {
}

/**
* Resolves a cross-model to-one relation's target facts so a {@code relation.field} recipient or
* placeholder can reference an entity owned by another model. The lookup performs the IO (reads the
* owner's {@code .model} via {@code CrossModelSupport}); {@code NotificationSupport} stays free of
* Spring/IO so its path logic remains directly unit-testable. Returns {@code null} when the
* relation is not a resolvable cross-model target.
*/
@FunctionalInterface
public interface CrossModelLookup {
CrossModelTarget resolve(RelationIntent relation);
}

/**
* The facts about a cross-model relation target a notification needs: the owner perspective and
* project/alias (to import the owner's generated Entity/Repository) plus its property names
* (PascalCase) to validate the referenced field. A {@code null} {@code propertyNames} means "not
* validated" (a naming-convention fallback) - the caller then trusts the authored field.
*/
public record CrossModelTarget(String perspectiveName, String project, String modelAlias, java.util.Set<String> propertyNames) {
}

/** The translated, ready-to-render shape of a notification. */
Expand Down Expand Up @@ -89,7 +117,24 @@ public static String topicSuffix(String eventKind) {
*/
public static Plan plan(NotificationIntent notification, EntityIntent eventEntity, Map<String, EntityIntent> byName,
Map<String, String> compositionParents) {
Resolver resolver = new Resolver(eventEntity, byName, compositionParents);
return plan(notification, eventEntity, byName, compositionParents, null);
}

/**
* Build the full translation plan, resolving cross-model {@code relation.field} paths through the
* given {@code crossModel} lookup (pass {@code null} to keep the local-only behavior, e.g. in unit
* tests).
*
* @param notification the notification
* @param eventEntity the entity whose event fires it
* @param byName all LOCAL entities by name (to resolve same-model relation targets)
* @param compositionParents composition-parent map (to resolve a target's perspective)
* @param crossModel resolver for a cross-model relation's owner facts, or {@code null}
* @return the plan, or {@code null} if the {@code to} recipient cannot be resolved
*/
public static Plan plan(NotificationIntent notification, EntityIntent eventEntity, Map<String, EntityIntent> byName,
Map<String, String> compositionParents, CrossModelLookup crossModel) {
Resolver resolver = new Resolver(eventEntity, byName, compositionParents, crossModel);
String to = resolver.value(notification.getTo());
if (to == null) {
return null; // an unresolvable recipient relation.field - skip rather than email garbage
Expand Down Expand Up @@ -148,12 +193,15 @@ private static final class Resolver {
private final EntityIntent entity;
private final Map<String, EntityIntent> byName;
private final Map<String, String> compositionParents;
private final CrossModelLookup crossModel;
private final Map<String, RelationLoad> loads = new LinkedHashMap<>();

Resolver(EntityIntent entity, Map<String, EntityIntent> byName, Map<String, String> compositionParents) {
Resolver(EntityIntent entity, Map<String, EntityIntent> byName, Map<String, String> compositionParents,
CrossModelLookup crossModel) {
this.entity = entity;
this.byName = byName;
this.compositionParents = compositionParents;
this.crossModel = crossModel;
}

List<RelationLoad> loads() {
Expand Down Expand Up @@ -220,14 +268,34 @@ private String access(String path) {
if (relation == null) {
return null;
}
String pascalField = IntentNaming.pascalCase(fieldName);
// Cross-model relation.field (e.g. Customer.email where Customer is owned by another model):
// resolve the owner's facts through the injected lookup and load from the OWNER's package.
// A same-model relation resolves against the local byName map as before.
if (relation.getModel() != null && !relation.getModel()
.isBlank()) {
CrossModelTarget xm = crossModel == null ? null : crossModel.resolve(relation);
if (xm == null) {
return null;
}
// Validate the field against the owner model when its properties are known; a naming-
// convention fallback carries null propertyNames - then trust the authored field.
if (xm.propertyNames() != null && !xm.propertyNames()
.contains(pascalField)) {
return null;
}
loads.computeIfAbsent(relationName, name -> new RelationLoad(name, relation.getTo(), xm.perspectiveName(),
IntentNaming.pascalCase(name), true, xm.modelAlias(), xm.project()));
return "(" + relationName + " == null ? null : " + relationName + "." + pascalField + ")";
}
EntityIntent target = byName.get(relation.getTo());
if (target == null || fieldOf(target, fieldName) == null) {
return null;
}
loads.computeIfAbsent(relationName, name -> new RelationLoad(name, relation.getTo(),
IntentEntities.resolvePerspective(relation.getTo(), compositionParents), IntentNaming.pascalCase(name)));
IntentEntities.resolvePerspective(relation.getTo(), compositionParents), IntentNaming.pascalCase(name), false, "", ""));
// The listener loads the related entity into a local named after the relation.
return "(" + relationName + " == null ? null : " + relationName + "." + IntentNaming.pascalCase(fieldName) + ")";
return "(" + relationName + " == null ? null : " + relationName + "." + pascalField + ")";
}

private RelationIntent toOneRelation(String name) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,13 @@ Treat it as the contract: anything you propose must parse and validate against i
- **Lifecycle events** (`notifications`, `integrations`): exactly **one** of `onCreate` / `onUpdate` /
`onDelete` per item, and it must reference a declared entity.
- **Recipients** (`to` on notifications and schedule notify): a literal email address, a direct field
of the entity, or a **one-hop** `relation.field` (e.g. `member.email`). Multi-hop paths are not
supported.
of the entity, or a **one-hop** `relation.field` (e.g. `member.email`). The relation may be
**cross-model** - `partner.email` where `partner` targets an entity owned by another `uses` model
resolves against the owner's model (the generated listener imports the owner's Entity/Repository),
exactly like a cross-model dropdown. Multi-hop paths are not supported.
- **A recipient that cannot be resolved is surfaced, not silent.** If `to` names a field/relation that
does not exist, that notification or schedule is dropped and reported in the generate response's
`warnings` (as well as the server log) - fix the reference so the glue is emitted.
- **Names are identifiers** within their block and must be unique.

## Capabilities
Expand Down
Loading
Loading