MiniJDL 项目说明
结课项目要求你设计一种小型语言,用于描述包含实体、字段和有向关系的应用。从同一个模型出发,检查领域规则,生成 SQL DDL、Java 数据类和 HTML 文档,再把实体转换成关系模型。这里的 MiniJDL 是教学语言,并非 JHipster 的 JDL;它生成的 Java 类是简单的数据声明,而不是可运行的 Web 应用。
从实体描述生成多个相互协调的产物这一思路改编自 Dimitris Kolovos 的 Minimal JHipster JDL Monolith Example(2021)。本节的 MiniJDL 元模型、商店模型和 Epsilon 程序均为教程编写。你可以在本地使用第 10 模块下载包,或在 Playground 中打开各示例。
目标是建立可重复的流程:元模型定义语言,模型记录应用,EVL 拒绝不良数据,EGL/EGX 生成可读产物,ETL 通过明确的映射创建另一种模型。每一步都留下可检查的证据,再决定是否信任下一步。
定义语言
打开第一个示例中的 minijdl.emf。Application 包含 Entity;Entity 包含 Field 和 Relation。字段的 FieldType 可以是 STRING、INTEGER 或 BOOLEAN。关系引用目标实体。val 明确所有权,ref 表示关系指向一个已有实体,而不是拥有它的副本。
Flexmi 模型描述 Shop:Customer 有 email 和 active,Product 有 title 和 price,Purchase 有 quantity,并与另外两个实体建立关系。EOL 查询打印数量,便于确认模型按预期加载。
var app = Application.all.first();
("Application: " + app.name).println();
for (e in app.entities) {
var fieldNoun = "fields";
if (e.fields.size() = 1) { fieldNoun = "field"; }
var relationNoun = "relations";
if (e.relations.size() = 1) { relationNoun = "relation"; }
(e.name + ": " + e.fields.size() + " " + fieldNoun + ", " + e.relations.size() + " " + relationNoun).println();
}
@namespace(uri="minijdl", prefix="minijdl")
package minijdl;
enum FieldType { STRING; INTEGER; BOOLEAN; }
class Application {
attr String[1] name;
val Entity[*] entities;
}
class Entity {
attr String[1] name;
val Field[*] fields;
val Relation[*] relations;
}
class Field {
attr String[1] name;
attr FieldType type;
}
class Relation {
attr String[1] name;
ref Entity target;
}
<?nsuri minijdl?>
<application name="Shop">
<entity name="Customer">
<field name="email" type="STRING"/>
<field name="active" type="BOOLEAN"/>
</entity>
<entity name="Product">
<field name="title" type="STRING"/>
<field name="price" type="INTEGER"/>
</entity>
<entity name="Purchase">
<field name="quantity" type="INTEGER"/>
<relation name="customer" target="Shop.Customer"/>
<relation name="product" target="Shop.Product"/>
</entity>
</application>
Application: Shop
Customer: 2 fields, 0 relations
Product: 2 fields, 0 relations
Purchase: 1 field, 2 relations
元模型能表达结构,却不能表达所有业务规则。例如,它并不阻止同一实体中的两个字段同名。该规则应放在验证中。若以后加入新的字段类型,就必须同时更新元模型中的枚举和各生成器中的类型映射。
构建并验证模型
下一个模型故意包含两个 Customer.email 字段。EVL 检查实体名称唯一、每个实体内部的字段名称唯一,并检查关系有目标。重复字段产生两条结果,因为约束会对两个违规字段分别执行。这是验证结果,而不是解析错误。
context Entity {
constraint UniqueEntityName {
check: Entity.all.select(e | e.name = self.name).size() = 1
message: "Duplicate entity: " + self.name
}
}
context Field {
constraint UniqueFieldName {
check: self.eContainer().fields.select(f | f.name = self.name).size() = 1
message: "Duplicate field: " + self.eContainer().name + "." + self.name
}
}
context Relation {
constraint HasTarget {
check: not self.target.isUndefined()
message: "Missing relation target: " + self.name
}
}
其他文件: shop.flexmi, minijdl.emf
<?nsuri minijdl?>
<application name="Shop">
<entity name="Customer">
<field name="email" type="STRING"/>
<field name="email" type="BOOLEAN"/>
</entity>
<entity name="Product">
<field name="title" type="STRING"/>
<field name="price" type="INTEGER"/>
</entity>
<entity name="Purchase">
<field name="quantity" type="INTEGER"/>
<relation name="customer" target="Shop.Customer"/>
<relation name="product" target="Shop.Product"/>
</entity>
</application>
@namespace(uri="minijdl", prefix="minijdl")
package minijdl;
enum FieldType { STRING; INTEGER; BOOLEAN; }
class Application {
attr String[1] name;
val Entity[*] entities;
}
class Entity {
attr String[1] name;
val Field[*] fields;
val Relation[*] relations;
}
class Field {
attr String[1] name;
attr FieldType type;
}
class Relation {
attr String[1] name;
ref Entity target;
}
Error [UniqueFieldName]: Duplicate field: Customer.email
Error [UniqueFieldName]: Duplicate field: Customer.email
生成交付产物前先修复重复字段。练习 4 的参考解答在修正后的模型上运行相同规则。真实项目还应酌情检查标识符语法、目标语言保留字、关系名与字段名冲突、必填字段和循环。通过这组简短规则,并不代表其他策略也已得到保证。
生成 SQL、Java 与 HTML
EGL 把商店模型映射为 SQL DDL。每个实体变成一张带 id 列的表;字段映射为 SQL 列类型;关系变成 _id 列和外键声明。检查生成的 SQL,确认 Purchase 按名称引用 Customer 和 Product。模板使用简单的通用 SQL 风格;生产环境必须明确选择数据库专属类型和命名规则。
[%
var ddl = "-- MiniJDL schema for " + Application.all.first().name + "\n";
for (e in Entity.all) {
ddl += "CREATE TABLE " + e.name + " (\n id INTEGER PRIMARY KEY";
for (f in e.fields) {
var sqlType = "INTEGER";
if (f.type.toString() = "STRING") { sqlType = "VARCHAR(255)"; }
else if (f.type.toString() = "BOOLEAN") { sqlType = "BOOLEAN"; }
ddl += ",\n " + f.name + " " + sqlType;
}
for (r in e.relations) {
ddl += ",\n " + r.name + "_id INTEGER";
}
for (r in e.relations) {
ddl += ",\n FOREIGN KEY (" + r.name + "_id) REFERENCES " + r.target.name + "(id)";
}
ddl += "\n);\n";
}
%][%=ddl%]
其他文件: shop.flexmi, minijdl.emf
<?nsuri minijdl?>
<application name="Shop">
<entity name="Customer">
<field name="email" type="STRING"/>
<field name="active" type="BOOLEAN"/>
</entity>
<entity name="Product">
<field name="title" type="STRING"/>
<field name="price" type="INTEGER"/>
</entity>
<entity name="Purchase">
<field name="quantity" type="INTEGER"/>
<relation name="customer" target="Shop.Customer"/>
<relation name="product" target="Shop.Product"/>
</entity>
</application>
@namespace(uri="minijdl", prefix="minijdl")
package minijdl;
enum FieldType { STRING; INTEGER; BOOLEAN; }
class Application {
attr String[1] name;
val Entity[*] entities;
}
class Entity {
attr String[1] name;
val Field[*] fields;
val Relation[*] relations;
}
class Field {
attr String[1] name;
attr FieldType type;
}
class Relation {
attr String[1] name;
ref Entity target;
}
-- MiniJDL schema for Shop
CREATE TABLE Customer (
id INTEGER PRIMARY KEY,
email VARCHAR(255),
active BOOLEAN
);
CREATE TABLE Product (
id INTEGER PRIMARY KEY,
title VARCHAR(255),
price INTEGER
);
CREATE TABLE Purchase (
id INTEGER PRIMARY KEY,
quantity INTEGER,
customer_id INTEGER,
product_id INTEGER,
FOREIGN KEY (customer_id) REFERENCES Customer(id),
FOREIGN KEY (product_id) REFERENCES Product(id)
);
EGX 针对每个实体调用一次 EGL 模板,得到 Customer.java、Product.java 和 Purchase.java。模板将字段类型映射到 Java 类型,将关系映射为有类型的引用。这些文件展示生成过程;Playground 不会编译它们,关系持久化也不在此小示例范围内。
rule Entity2Java
transform e : Entity {
template: "template.egl"
target: e.name + ".java"
}
public class [%=e.name%] {
public int id;
[% for (f in e.fields) { %] public [% if (f.type.toString() = "STRING") { %]String[% } else if (f.type.toString() = "BOOLEAN") { %]boolean[% } else { %]int[% } %] [%=f.name%];
[% } %][% for (r in e.relations) { %] public [%=r.target.name%] [%=r.name%];
[% } %]}
其他文件: shop.flexmi, minijdl.emf
<?nsuri minijdl?>
<application name="Shop">
<entity name="Customer">
<field name="email" type="STRING"/>
<field name="active" type="BOOLEAN"/>
</entity>
<entity name="Product">
<field name="title" type="STRING"/>
<field name="price" type="INTEGER"/>
</entity>
<entity name="Purchase">
<field name="quantity" type="INTEGER"/>
<relation name="customer" target="Shop.Customer"/>
<relation name="product" target="Shop.Product"/>
</entity>
</application>
@namespace(uri="minijdl", prefix="minijdl")
package minijdl;
enum FieldType { STRING; INTEGER; BOOLEAN; }
class Application {
attr String[1] name;
val Entity[*] entities;
}
class Entity {
attr String[1] name;
val Field[*] fields;
val Relation[*] relations;
}
class Field {
attr String[1] name;
attr FieldType type;
}
class Relation {
attr String[1] name;
ref Entity target;
}
--- Customer.java ---
public class Customer {
public int id;
public String email;
public boolean active;
}
--- Product.java ---
public class Product {
public int id;
public String title;
public int price;
}
--- Purchase.java ---
public class Purchase {
public int id;
public int quantity;
public Customer customer;
public Product product;
}
HTML 模板提供同一模型的第三种视图,列出实体、字段和关系数量。打开生成输出,把名称与 SQL、Java 产物比较。如果某个生成器漏掉实体,共同的源模型有助于发现差异。
<h1>[%=Application.all.first().name%] entities</h1>
[% for (e in Entity.all) { %]<section><h2>[%=e.name%]</h2><ul>
[% for (f in e.fields) { %]<li>[%=f.name%]: [%=f.type%]</li>
[% } %]</ul><p>Relations: [%=e.relations.size()%]</p></section>
[% } %]
其他文件: shop.flexmi, minijdl.emf
<?nsuri minijdl?>
<application name="Shop">
<entity name="Customer">
<field name="email" type="STRING"/>
<field name="active" type="BOOLEAN"/>
</entity>
<entity name="Product">
<field name="title" type="STRING"/>
<field name="price" type="INTEGER"/>
</entity>
<entity name="Purchase">
<field name="quantity" type="INTEGER"/>
<relation name="customer" target="Shop.Customer"/>
<relation name="product" target="Shop.Product"/>
</entity>
</application>
@namespace(uri="minijdl", prefix="minijdl")
package minijdl;
enum FieldType { STRING; INTEGER; BOOLEAN; }
class Application {
attr String[1] name;
val Entity[*] entities;
}
class Entity {
attr String[1] name;
val Field[*] fields;
val Relation[*] relations;
}
class Field {
attr String[1] name;
attr FieldType type;
}
class Relation {
attr String[1] name;
ref Entity target;
}
<h1>Shop entities</h1>
<section><h2>Customer</h2><ul>
<li>email: STRING</li>
<li>active: BOOLEAN</li>
</ul><p>Relations: 0</p></section>
<section><h2>Product</h2><ul>
<li>title: STRING</li>
<li>price: INTEGER</li>
</ul><p>Relations: 0</p></section>
<section><h2>Purchase</h2><ul>
<li>quantity: INTEGER</li>
</ul><p>Relations: 2</p></section>
转换为关系模型
文本生成不是唯一途径。ETL 程序把 Application 映射为 Schema,Entity 映射为 Table,Field 映射为 Column。目标遵循自己的 relational.emf 元模型,因此可以继续验证或交给其他生成器。初始映射故意省略关系,便于观察信息损失。
rule Application2Schema
transform a : Source!Application
to s : Target!Schema {
s.name = a.name;
s.tables ::= a.entities;
}
rule Entity2Table
transform e : Source!Entity
to t : Target!Table {
t.name = e.name;
var id = new Target!Column;
id.name = "id";
id.sqlType = "INTEGER";
t.columns ::= e.fields;
t.columns.add(id);
}
rule Field2Column
transform f : Source!Field
to c : Target!Column {
c.name = f.name;
if (f.type.toString() = "STRING") { c.sqlType = "VARCHAR(255)"; }
else if (f.type.toString() = "BOOLEAN") { c.sqlType = "BOOLEAN"; }
else { c.sqlType = "INTEGER"; }
}
@namespace(uri="relational", prefix="relational")
package relational;
class Schema {
attr String name;
val Table[*] tables;
}
class Table {
attr String name;
val Column[*] columns;
val ForeignKey[*] foreignKeys;
}
class Column {
attr String name;
attr String sqlType;
}
class ForeignKey {
attr String name;
attr String column;
ref Table target;
}
其他文件: shop.flexmi, minijdl.emf
<?nsuri minijdl?>
<application name="Shop">
<entity name="Customer">
<field name="email" type="STRING"/>
<field name="active" type="BOOLEAN"/>
</entity>
<entity name="Product">
<field name="title" type="STRING"/>
<field name="price" type="INTEGER"/>
</entity>
<entity name="Purchase">
<field name="quantity" type="INTEGER"/>
<relation name="customer" target="Shop.Customer"/>
<relation name="product" target="Shop.Product"/>
</entity>
</application>
@namespace(uri="minijdl", prefix="minijdl")
package minijdl;
enum FieldType { STRING; INTEGER; BOOLEAN; }
class Application {
attr String[1] name;
val Entity[*] entities;
}
class Entity {
attr String[1] name;
val Field[*] fields;
val Relation[*] relations;
}
class Field {
attr String[1] name;
attr FieldType type;
}
class Relation {
attr String[1] name;
ref Entity target;
}
Schema name="Shop"
Table name="Customer"
Column name="email" sqlType="VARCHAR(255)"
Column name="active" sqlType="BOOLEAN"
Column name="id" sqlType="INTEGER"
Table name="Product"
Column name="title" sqlType="VARCHAR(255)"
Column name="price" sqlType="INTEGER"
Column name="id" sqlType="INTEGER"
Table name="Purchase"
Column name="quantity" sqlType="INTEGER"
Column name="id" sqlType="INTEGER"
将目标树与源模型比较:三张表包含五个已声明字段的列,以及三个生成的 id 列,却没有外键。ETL 规则在 t.columns ::= e.fields 后添加 id,因为该赋值会替换先前的列集合。练习 6 加入关系键列和 Relation2ForeignKey 规则,并将其结果挂到正确的表下。转换成功运行不代表映射完整;应先确定目标必须满足的不变量,再判断工作是否完成。
评估并扩展解答
用下面的清单评估自己的 MiniJDL 变体:
- 语言: 每个类、包含关系和引用是否都有用途?模型元素的所有权是否如预期?
- 模型: 所有按名称编写的引用都能解析吗?不修改元模型,能否描述第二个不同的应用?
- 验证: 无效名称和关系是否产生清楚的结果?修正后的模型是否通过?还有哪些规则尚未实现?
- SQL: 所有实体、字段和关系键是否存在?输出对所选数据库是否有效?
- Java: 预期的文件和类型是否存在?如需可编译代码,是否已编译并处理包名与导入?
- HTML: 文档是否描述了与模型一致的实体和关系?
- ETL: 表和列数量是否正确?每个外键是否指向预期目标表?目标模型能否验证?
- 可重复性: 修改模型后,能否重新运行流程、比较输出,而不手工修补产物?
参考示例提供了小型端到端解答,并不生成完整的 JHipster 应用。加入重数、约束、应用配置和持久化选项,将成为新的设计练习。
练习
追踪所有权。 为什么 Entity 包含自己的 Field,而 Relation.target 是引用?
查看解答
字段归声明它的实体所有。关系指向应用中已有的另一个实体;若包含该目标,就会复制或移动它。
追踪元素。 在 SQL、Java 和 HTML 示例中追踪 Purchase.customer。哪些表示包含目标名称?HTML 示例省略了什么?
查看解答
SQL 声明 customer_id 和指向 Customer 的外键;Java 声明 Customer customer 字段。HTML 示例只报告 Purchase 有两个关系,未列出关系名称和目标,因此它只是摘要,不是完整的模式说明。
查找信息损失。 第一个关系模型目标省略了什么?即使 ETL 已结束,为什么还要检查?
查看解答
它省略了 Purchase 的两条关系,因此目标中没有外键。ETL 执行成功只能说明规则运行过,不能证明所有必需的源概念都已映射。
修复模型。 将重复的 Customer.email 字段改为 active: BOOLEAN,然后运行相同的 EVL 规则。
查看解答
修正后的模型通过这组参考规则中的全部三个约束:
context Entity {
constraint UniqueEntityName {
check: Entity.all.select(e | e.name = self.name).size() = 1
message: "Duplicate entity: " + self.name
}
}
context Field {
constraint UniqueFieldName {
check: self.eContainer().fields.select(f | f.name = self.name).size() = 1
message: "Duplicate field: " + self.eContainer().name + "." + self.name
}
}
context Relation {
constraint HasTarget {
check: not self.target.isUndefined()
message: "Missing relation target: " + self.name
}
}
其他文件: shop.flexmi, minijdl.emf
<?nsuri minijdl?>
<application name="Shop">
<entity name="Customer">
<field name="email" type="STRING"/>
<field name="active" type="BOOLEAN"/>
</entity>
<entity name="Product">
<field name="title" type="STRING"/>
<field name="price" type="INTEGER"/>
</entity>
<entity name="Purchase">
<field name="quantity" type="INTEGER"/>
<relation name="customer" target="Shop.Customer"/>
<relation name="product" target="Shop.Product"/>
</entity>
</application>
@namespace(uri="minijdl", prefix="minijdl")
package minijdl;
enum FieldType { STRING; INTEGER; BOOLEAN; }
class Application {
attr String[1] name;
val Entity[*] entities;
}
class Entity {
attr String[1] name;
val Field[*] fields;
val Relation[*] relations;
}
class Field {
attr String[1] name;
attr FieldType type;
}
class Relation {
attr String[1] name;
ref Entity target;
}
All constraints are satisfied.
扩展 Java 生成。 在每个生成的 Java 类中加入返回实体名称的 entityName() 方法。
查看解答
EGX 规则仍为每个实体创建一个文件;EGL 模板新增该方法:
rule Entity2Java
transform e : Entity {
template: "template.egl"
target: e.name + ".java"
}
public class [%=e.name%] {
public int id;
[% for (f in e.fields) { %] public [% if (f.type.toString() = "STRING") { %]String[% } else if (f.type.toString() = "BOOLEAN") { %]boolean[% } else { %]int[% } %] [%=f.name%];
[% } %][% for (r in e.relations) { %] public [%=r.target.name%] [%=r.name%];
[% } %] public String entityName() { return "[%=e.name%]"; }
}
其他文件: shop.flexmi, minijdl.emf
<?nsuri minijdl?>
<application name="Shop">
<entity name="Customer">
<field name="email" type="STRING"/>
<field name="active" type="BOOLEAN"/>
</entity>
<entity name="Product">
<field name="title" type="STRING"/>
<field name="price" type="INTEGER"/>
</entity>
<entity name="Purchase">
<field name="quantity" type="INTEGER"/>
<relation name="customer" target="Shop.Customer"/>
<relation name="product" target="Shop.Product"/>
</entity>
</application>
@namespace(uri="minijdl", prefix="minijdl")
package minijdl;
enum FieldType { STRING; INTEGER; BOOLEAN; }
class Application {
attr String[1] name;
val Entity[*] entities;
}
class Entity {
attr String[1] name;
val Field[*] fields;
val Relation[*] relations;
}
class Field {
attr String[1] name;
attr FieldType type;
}
class Relation {
attr String[1] name;
ref Entity target;
}
--- Customer.java ---
public class Customer {
public int id;
public String email;
public boolean active;
public String entityName() { return "Customer"; }
}
--- Product.java ---
public class Product {
public int id;
public String title;
public int price;
public String entityName() { return "Product"; }
}
--- Purchase.java ---
public class Purchase {
public int id;
public int quantity;
public Customer customer;
public Product product;
public String entityName() { return "Purchase"; }
}
完成关系映射。 加入 ETL 规则,为每条关系创建外键,并通过转换轨迹解析目标表。
查看解答
Entity2Table 添加每个 _id 列并挂载转换后的关系;Relation2ForeignKey 将关系目标实体映射到相应的表。目标树现在于 Purchase 下包含 customer 和 product 外键:
rule Application2Schema
transform a : Source!Application
to s : Target!Schema {
s.name = a.name;
s.tables ::= a.entities;
}
rule Entity2Table
transform e : Source!Entity
to t : Target!Table {
t.name = e.name;
var id = new Target!Column;
id.name = "id";
id.sqlType = "INTEGER";
t.columns ::= e.fields;
t.columns.add(id);
for (r in e.relations) {
var column = new Target!Column;
column.name = r.name + "_id";
column.sqlType = "INTEGER";
t.columns.add(column);
}
t.foreignKeys ::= e.relations;
}
rule Field2Column
transform f : Source!Field
to c : Target!Column {
c.name = f.name;
if (f.type.toString() = "STRING") { c.sqlType = "VARCHAR(255)"; }
else if (f.type.toString() = "BOOLEAN") { c.sqlType = "BOOLEAN"; }
else { c.sqlType = "INTEGER"; }
}
rule Relation2ForeignKey
transform r : Source!Relation
to fk : Target!ForeignKey {
fk.name = r.name;
fk.column = r.name + "_id";
fk.target ::= r.target;
}
@namespace(uri="relational", prefix="relational")
package relational;
class Schema {
attr String name;
val Table[*] tables;
}
class Table {
attr String name;
val Column[*] columns;
val ForeignKey[*] foreignKeys;
}
class Column {
attr String name;
attr String sqlType;
}
class ForeignKey {
attr String name;
attr String column;
ref Table target;
}
其他文件: shop.flexmi, minijdl.emf
<?nsuri minijdl?>
<application name="Shop">
<entity name="Customer">
<field name="email" type="STRING"/>
<field name="active" type="BOOLEAN"/>
</entity>
<entity name="Product">
<field name="title" type="STRING"/>
<field name="price" type="INTEGER"/>
</entity>
<entity name="Purchase">
<field name="quantity" type="INTEGER"/>
<relation name="customer" target="Shop.Customer"/>
<relation name="product" target="Shop.Product"/>
</entity>
</application>
@namespace(uri="minijdl", prefix="minijdl")
package minijdl;
enum FieldType { STRING; INTEGER; BOOLEAN; }
class Application {
attr String[1] name;
val Entity[*] entities;
}
class Entity {
attr String[1] name;
val Field[*] fields;
val Relation[*] relations;
}
class Field {
attr String[1] name;
attr FieldType type;
}
class Relation {
attr String[1] name;
ref Entity target;
}
Schema name="Shop"
Table name="Customer"
Column name="email" sqlType="VARCHAR(255)"
Column name="active" sqlType="BOOLEAN"
Column name="id" sqlType="INTEGER"
Table name="Product"
Column name="title" sqlType="VARCHAR(255)"
Column name="price" sqlType="INTEGER"
Column name="id" sqlType="INTEGER"
Table name="Purchase"
Column name="quantity" sqlType="INTEGER"
Column name="id" sqlType="INTEGER"
Column name="customer_id" sqlType="INTEGER"
Column name="product_id" sqlType="INTEGER"
ForeignKey name="customer" column="customer_id" target->Customer
ForeignKey name="product" column="product_id" target->Product
自测
Field?Relation2ForeignKey 如何找到目标表?延伸阅读
- Dimitris Kolovos 的 Minimal JHipster JDL Monolith Example(2021)提供了生成整个应用的动机示例。
- EVL、EGL、EGX和 ETL参考文档有助于扩展流程。