1

I want a Tag table that's able to apply tags to various kinds of entities. In SQL it looks like this:

CREATE TABLE tag (
  id number GENERATED ALWAYS AS IDENTITY NOT NULL,
  resource_type varchar2(64) NOT NULL,
  resource_id varchar2(256),
  namespace_id varchar2(256),
  tag varchar2(128),
  time_created timestamp with time zone NOT NULL,
  PRIMARY KEY (resource_type, namespace_id, tag),
  CHECK (resource_type in ('post', 'story'))
);

If resource_type is post, then the resource_id is meant to join to the Post table's id field (and likewise for Story). (The namespace_id field is there because while two Posts are allowed have the same tag string, all my entities are grouped into namespaces, and two entities in the same namespace cannot have the same tag. Hopefully that's irrelevant.)

I'm not sure how the entities are supposed to look. I tried something like this:

@Entity
@Table(name = "post")
public class Post {
    @Id
    private String id;

...

    @NonNull
    @Default
    @OneToMany(fetch = FetchType.EAGER, targetEntity=Tag.class)
//    @JoinColumn(name = "resource_id")
    @Where(clause = "resource_id=post.id and resource_type='post'")
    @ElementCollection
    private List<Tag> tags = new ArrayList<>();
}

I'm sure that's not right, and I'm not sure there's even a way to do this. On the Tag entity side, I don't have a @ManyToOne because it joins with various different entities.

A_P
  • 326
  • 1
  • 12

1 Answers1

3

I understand you want a tag table representing tags for multiple different entities rather than a tag table + join tables for specific entity types (post_tags, story_tags etc.), which is how JPA would map a unidirectional one-to-many by default.

In that case I believe this is what you're looking for.

There are essentially three ways to go around this:

1. @Where + @Any

Use @Where to restrict matching entities in the Post.tags collection:

@Entity public class Post {

    @Id
    private String id;

    @OneToMany
    @Immutable
    @JoinColumn(name = "resource_id", referencedColumnName = "id", insertable = false, updatable = false)
    @Where(clause = "resource_type = 'post'")
    private Collection<Tag> tags;
}

Then, in Tag use @Any to define a multi-targeted association:

@Entity public class Tag {

    @Id
    private Long id;

    private String tag;

    @CreationTimestamp
    private Instant timeCreated;

    @JoinColumn(name = "resource_id")
    @Any(metaColumn = @Column(name = "resource_type"), optional = false, fetch = LAZY)
    @AnyMetaDef(idType = "string", metaType = "string",
            metaValues = {
                    @MetaValue(value = "post", targetEntity = Post.class),
                    @MetaValue(value = "story", targetEntity = Story.class),
            })
    private Object resource;
}

Adding a new Tag to a Post is straightforward, just assign the Post to the Tag.resource property (same for stories and all the other 'taggable' entities)

(Note that you might want to add a base class/marker interface like Taggable and use it instead of Object to restrict the types one may assign to the Tag.resource property. It should work, but I haven't tested it, so I'm not 100% sure)

2. @Where + explicit join column mapping in Tag

Use the same approach as before for Post and map the resource_id and resource_type columns as explicit properties:

@Entity public class Tag {

    @Id
    private Long id;

    private String tag;

    @CreationTimestamp
    private Instant timeCreated;

    @Column(name = "resource_id")
    private String resourceId;

    private String resourceType;
}

Creating a new Tag now requires you to populate resourceId and resourceType yourself. This approach makes a lot of sense if you want to treat Post and Tag as separate aggregate roots, otherwise it's pretty cumbersome and error-prone, as Hibernate does not help you ensure consistency, you need to manage it yourself.

3. Inheritance + mappedBy

Create separate entities for post tags, story tags etc. with the single inheritance strategy and treating the resource_type column as the discriminator value:

@Entity
@Inheritance(strategy = SINGLE_TABLE)
@DiscriminatorColumn(name = "resource_type")
public abstract class Tag {

    @Id
    private Long id;

    private String tag;

    @CreationTimestamp
    private Instant timeCreated;
}

@Entity
@DiscriminatorValue("post")
public class PostTag extends Tag {

    @JoinColumn(name = "resource_id")
    @ManyToOne(optional = false, fetch = LAZY)
    private Post post;
}

@Entity
@DiscriminatorValue("story")
public class StoryTag extends Tag {

    @JoinColumn(name = "resource_id")
    @ManyToOne(optional = false, fetch = LAZY)
    private Story story;
}

This solution has the advantage that, in the 'taggable' entity, you no longer need a 'fake' owning @OneToMany association but can instead use mappedBy:

@Entity public class Post {

    @Id
    private String id;

    @OneToMany(mappedBy = "post")
    private Collection<PostTag> tags;
}

@Entity public class Story {

    @Id
    private String id;

    @OneToMany(mappedBy = "story")
    private Collection<StoryTag> tags;
}

Adding a new Tag is also simplified (Want a new post tag? Create a PostTag object. Want a new story tag? Create a StoryTag object instead). In addition, if you ever want to switch to managing Tags using the Post.tags association (i.e. a unidirectional one-to-many), this approach will be the easiest to convert.

(note that in this scenario, you cannot, of course, rely on Hibernate to generate the schema, because it will then try to create FK constraints on resource_id column pointing to all the candidate tables)

I created a github repo with all three approaches represented as separate commits. For each approach, there's a test proving that it actually works. Note that the database structure is the same for all three scenarios.

(As a side note, I only now noticed the PRIMARY KEY (resource_type, namespace_id, tag) part of the table definition, so I have to ask: you do understand this question is asked and answered with a one-to-many association in mind, and not many-to-many, right?

I'm asking because with such a PK definition, at most one postcould have a tag with a given value of the tag column - for a given namespace_id, of course. I'm assuming that's a typo and what you really wanted was a PRIMARY KEY(id) coupled with UNIQUE(resource_type, resource_id, namespace_id, tag))

crizzis
  • 9,978
  • 2
  • 28
  • 47
  • Thanks! I assume that should be `@Where(clause = "resource_type='blueprint'")`? I tried it, and when I query my Posts, none of them have Tags still. What should the Tag entity look like? I haven't put any join-related annotations there. (It has a `@Column(name="resource_id")`, of course). – A_P Aug 14 '20 at 22:04
  • One thing that might be wrong is that the java field names are camelcase but the `clause` and `name` fields in those annotations use underscores. But now I've tried both, so maybe that's not it. – A_P Aug 14 '20 at 22:13
  • Huh, that's weird, it should work out of the box. You shouldn't even need `@Column(name="resource_id")`, since Hibernate will use the column definition from `@JoinColumn`. In any case, I would strongly recommend `@ManyToAny` over `@Where`, because of potential issues with saving the entities – crizzis Aug 14 '20 at 22:14
  • Also, regarding camelcase vs underscore, `clause` and `name` use physical column names, not Java field names (`@Where` represents exactly the string that will be appended to the native `SELECT` query when fetching the children) – crizzis Aug 14 '20 at 22:17
  • "You shouldn't even need `@Column(name="resource_id")`, since Hibernate will use the column definition from `@JoinColumn`." I'm confused. The `JoinColumn` shows a property name `resource_id`. For that to work, don't I have to put a `@Column` annotation on `Tag` so that its `resourceId` field is called `resource_id`? As for ManyToAny, I'm having a hard time understanding it. This post seems to suggest that I only need `@Any`: https://stackoverflow.com/questions/217831/how-to-use-hibernate-any-related-annotations. It would go on the Tag entity. Not sure what to put on the Post entity. – A_P Aug 14 '20 at 22:49
  • Yeah, you're right, `@ManyToAny` won't work in your case, my bad. Sorry for wasting your time. As regards the `resource_id` column, you don't need a mapping for that column in `Tag` at all, since you already defined it as the `@JoinColumn` for the association. Hibernate will use that definition. – crizzis Aug 14 '20 at 23:01
  • No problem at all! Are you saying that by specifying `@JoinColumn(name='resource_id')`, I don't even have to specify a `resourceId` field in `Tag`? That one will be created for me? – A_P Aug 14 '20 at 23:09
  • Yup, that's exactly what I mean, the column will be created in the table – crizzis Aug 14 '20 at 23:19
  • In that case, how do I insert a new `Tag` object correctly, since I can't specify which `Post` (e.g.) it belongs to? I think I may have some deep misunderstandings here. I'd like to be able to insert into the `Tag` table and have the tags automatically appear in `Post` queries, in case that isn't obvious. Thanks again for your help here! – A_P Aug 14 '20 at 23:28
  • Indeed, there is *some* misunderstanding in that I assumed you wanted to manage the association from the `Post` side (i.e. by adding/removing `Tags` from the `Post.tags` collection). If that's not the case, then you need either `resourceId` mapped to the join column or, better still, a `resource` property using `@Any` in the `Tag` entity. I'll try to post a more detailed answer tomorrow if you don't mind – crizzis Aug 14 '20 at 23:49
  • Thanks very much! #2 appears to be exactly what I want. Looks like I was almost there, but not quite :) `referencedColumnName` was the bit I didn't know about. Unfortunately it still doesn't seem to work, but I suspect it may be related to this question of mine: https://stackoverflow.com/questions/63457714/why-does-my-jql-query-return-a-different-result-than-the-equivalent-criteriabuil – A_P Aug 18 '20 at 02:57
  • Woo! I got your second approach to work! I can't tell you how grateful I am for you to help me through this. I am confident I would not have figured it out on my own. – A_P Aug 20 '20 at 23:25
  • 1
    FYI, the `@JoinColumn` on `Post` also needs a `foreignKey=@ForeignKey(value=ConstraintMode.NO_CONSTRAINT)` to prevent `Tag` from generating a foreign key constraint (which prevents it from joining with more than one entity). – A_P Aug 25 '20 at 17:59
  • Nice trick, I completely forgot `@ForeignKey` exists – crizzis Aug 25 '20 at 20:48