Entity Engine Relations
Entity relationships are ordinary typed properties backed by validated mapping metadata. The Entity Engine manages ownership, foreign keys, loading, identity fix-up, cascades, orphan removal, and inheritance without exposing persistence proxies in application source.
| Annotation | Cardinality | Default fetch | Typical owner |
|---|---|---|---|
@OneToOne | One source to zero or one target | EAGER | Endpoint carrying @JoinColumn |
@OneToMany | One source to many targets | LAZY | Usually the target's @ManyToOne |
@ManyToOne | Many sources to one target | EAGER | Endpoint carrying the foreign key |
@ManyToMany | Many sources to many targets | LAZY | Endpoint carrying @JoinTable |
The owning endpoint defines the physical association. The inverse endpoint declares
mappedBy="propertyName", where the property name belongs to the target entity.
Both endpoints still expose direct entity values such as post.blog and
blog.posts.
The following model is based on the relational integration scenario in
tests/klyn/data/db/OneToManyRelationTest.kn. Post.blog owns the foreign key;
Blog.posts is its inverse collection.
import klyn.collections
import klyn.data.mapping
import klyn.data.sql
import klyn.time
@Entity
@Table(name="T_Blogs")
class Blog:
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
@Column(name="idBlog")
public property identifier as Int = 0
@Column(nullable=false)
public property url as String
@Column(nullable=false)
public property name as String
@Column(nullable=false)
public property description as String
@OneToMany(mappedBy="blog", cascade=[CascadeType.PERSIST])
public property posts as List<Post> = []
public Blog():
this("", "", "")
public Blog(url as String, name as String, description as String):
this.url = url
this.name = name
this.description = description
public addPost(post as Post) as Void:
post.blog = this
this.posts.add(post)
@Entity
@Table(name="T_Posts")
class Post:
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
public property idPost as Int = 0
@Column(nullable=false)
public property date as DateTime
@Column(nullable=false)
public property title as String
@Column(nullable=false)
public property content as String
@ManyToOne(optional=false)
@JoinColumn(name="idBlog", nullable=false)
public property blog as Blog
public Post(title as String = "", content as String = ""):
this.date = DateTime.now
this.title = title
this.content = content
A helper such as addPost() updates the collection and the owning reference together.
The Entity Engine validates and persists the graph, but domain code should not deliberately
leave its two in-memory endpoints contradictory.
Every relationship target must be part of the factory's entity model. A missing target is a mapping error raised while the factory is acquired, before schema or query work starts.
configuration = SQLConfiguration(
"mariadb://localhost:3306/klyn", # Database URL
"klyn", # Login
"secret" # Password
)
try factory = EntityManagerFactory(
configuration,
entities=[Blog, Post]
):
factory.schema.validate()
CascadeType.PERSIST on Blog.posts lets one call register both the blog and
its new posts. Cascades are opt-in; without this annotation each transient entity must be
persisted explicitly.
try em = factory.createEntityManager():
transaction = em.beginTransaction()
try:
blog = Blog(
"https://klyn.blog.org",
"A blog about Klyn",
"The Klyn blog description"
)
blog.addPost(Post("First post", "First description"))
blog.addPost(Post("Second post", "Second description"))
em.persist(blog)
em.saveChanges()
transaction.commit()
catch error as Exception:
if transaction.active:
transaction.rollback()
throw error
A lazy relationship can be loaded while its owning manager remains open. Accessing an unloaded
relationship after that manager has closed raises UnloadedRelationshipException.
Use join fetch when the relationship must leave the persistence context already loaded.
try em = factory.createEntityManager():
blog = (query(em):
from Blog b
left join fetch b.posts
where b.identifier == requestedId
).single()
# The collection was fetched explicitly and remains available here.
for post in blog.posts:
print(post.title)
A fetch join is a loading directive, not a second query root. It does not introduce another alias and does not change the query's result type. Nested paths can be fetched when every path segment is a mapped relationship.
A one-to-one owner carries the join column. orphanRemoval=true schedules deletion of
the former owned target when the association is severed and changes are saved.
@OneToOne(
cascade=[CascadeType.ALL],
orphanRemoval=true
)
@JoinColumn(
name="idInformations",
referencedColumnName="idInformations",
nullable=true
)
public property informations as UserInformations = null
The inverse endpoint, when required, declares only
@OneToOne(mappedBy="informations"). It must not duplicate the join column.
A many-to-one property normally owns a non-unique foreign key. Align the logical optionality and SQL nullability; contradictory explicit settings are rejected during mapping validation.
@ManyToOne(optional=false)
@JoinColumn(
name="idUser",
referencedColumnName="idUser",
nullable=false
)
public property user as User
The owning endpoint declares the join table. The inverse endpoint names the owning property with
mappedBy and must not redeclare physical columns.
@ManyToMany(cascade=[CascadeType.PERSIST, CascadeType.MERGE])
@JoinTable(
name="T_UserRoles",
joinColumn="idUser",
inverseJoinColumn="idRole"
)
public property roles as List<Role> = []
# In Role:
@ManyToMany(mappedBy="roles")
public property users as List<User> = []
@JoinColumns and the plural column arguments of @JoinTable support
composite identities. Column order must match the mapped key order.
| Cascade | Propagated lifecycle operation |
|---|---|
PERSIST | Register a transient related entity for insertion. |
MERGE | Merge detached related state. |
REMOVE | Schedule related entities for deletion. |
REFRESH | Reload related managed state. |
DETACH | Detach related managed entities. |
ALL | Enable every lifecycle cascade above. |
No cascade is implicit. orphanRemoval is separate from remove cascading: it applies
when an owner-controlled to-one value is replaced or an item leaves an owner-controlled
collection.
@Inheritance supports SINGLE_TABLE, JOINED, and
TABLE_PER_CLASS. A discriminator identifies the concrete type when the selected
strategy needs one.
@Entity
@Table(name="T_Payments")
@Inheritance(strategy=InheritanceType.JOINED)
@DiscriminatorColumn(name="paymentKind", length=32)
public abstract class Payment:
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
public property id as Int = 0
public property amount as Double
@Entity
@Table(name="T_PayPalPayments")
@DiscriminatorValue(value="PAYPAL")
public class PayPalPayment extends Payment:
public property accountNumber as String
Include the root and every concrete entity used by the application in the factory model. Queries over the root materialize the correct concrete subtype and preserve identity-map reuse.
Continue with Entity Engine KQL for filters, projections, ordering, pagination, fetch joins, and terminal operations.