Skip to content

Instantly share code, notes, and snippets.

@maludwig
Created November 12, 2018 06:45
Show Gist options
  • Save maludwig/12c168ad0b610696b2ca89124547f404 to your computer and use it in GitHub Desktop.
Save maludwig/12c168ad0b610696b2ca89124547f404 to your computer and use it in GitHub Desktop.
Jackson deduping resolver

I'm not sure if this remains an open issue or not, but I figured it might help to have a minimal project with an obvious use-case. So I've cut down a project I'm working on here to be as small as I can make it.

I want to make a library API. Where users can add Books, which have a ManyToMany relationship with Authors. A book may have multiple Authors, and an Author may write multiple books, so if we serialize a book to JSON and include all of the information about all of its Authors, and then we serialize all of those, and then they all have information about their Books, so then we serialize all those...

An Author is very simple, the name of the author is the ID. That's it. A Book is an ID, and a title. They have a ManyToMany relationship between them.

So we could POST a book like this, which would make a book, and map it to an author, which we may already have in the database, or which we might be making from her scratch:

POST /book
{
    "title": "Harry Potter and the Philosopher's Stone",
    "authors": [
        {
            "name": "JK Rowling"
        }
    ]
}

Works great! But then we would need to make one HTTP request per book, which could incur a performance constraint. So we want to have another endpoint for adding lots of them:

POST /books
[
    {
        "title": "Harry Potter and the Philosopher's Stone",
        "authors": [
            {
                "name": "JK Rowling"
            }
        ]
    },
    {
        "title": "Harry Potter and the Chamber of Secrets",
        "authors": [
            {
                "name": "JK Rowling"
            }
        ]
    }
]

Unfortunately this will be a problem, because Jackson will deserialize the two "JK Rowling" separately, and bomb out, and even though they are the exact same object, Jackson doesn't understand how to map them together. My solution was to have the two objects simply resolve to be the same thing. The exact same object reference.

In the Entity:

    @JsonIdentityInfo(
            generator=ObjectIdGenerators.PropertyGenerator.class, 
            property="name", 
            scope = Author.class, 
            resolver = DedupingObjectIdResolver.class
    )

The ObjectIdResolver class:

package hello;

import com.fasterxml.jackson.annotation.ObjectIdGenerator.IdKey;
import com.fasterxml.jackson.annotation.ObjectIdResolver;
import com.fasterxml.jackson.annotation.SimpleObjectIdResolver;

import java.util.HashMap;

public class DedupingObjectIdResolver extends SimpleObjectIdResolver {
    @Override
    public void bindItem(IdKey id, Object ob) {
        if (_items == null) {
            _items = new HashMap<>();
        }
        _items.put(id, ob);
    }

    @Override
    public ObjectIdResolver newForDeserialization(Object context) {
        return new DedupingObjectIdResolver();
    }
}

I'll upload the final project code in a sec here.

package hello;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
package hello;
import com.fasterxml.jackson.annotation.JsonIdentityInfo;
import com.fasterxml.jackson.annotation.JsonIdentityReference;
import com.fasterxml.jackson.annotation.ObjectIdGenerators;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.ManyToMany;
import java.util.Set;
@Entity
public class Author {
@Id
private String name;
@ManyToMany(mappedBy = "authors")
@JsonIdentityInfo(generator=ObjectIdGenerators.PropertyGenerator.class, property="id", scope = Book.class)
@JsonIdentityReference(alwaysAsId=true)
private Set<Book> books;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Set<Book> getBooks() {
return books;
}
public void setBooks(Set<Book> books) {
this.books = books;
}
}
package hello;
import org.springframework.data.repository.CrudRepository;
public interface AuthorRepository extends CrudRepository<Author, String> {
}
package hello;
import com.fasterxml.jackson.annotation.JsonIdentityInfo;
import com.fasterxml.jackson.annotation.JsonIdentityReference;
import com.fasterxml.jackson.annotation.ObjectIdGenerators;
import javax.persistence.*;
import java.util.Set;
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String title;
@ManyToMany()
@JoinTable(name = "book_to_author",
joinColumns = @JoinColumn(name = "book_id"),
inverseJoinColumns = @JoinColumn(name = "author_name")
)
@JsonIdentityInfo(
generator=ObjectIdGenerators.PropertyGenerator.class,
property="name",
scope = Author.class,
resolver = DedupingObjectIdResolver.class
)
@JsonIdentityReference(alwaysAsId=true)
private Set<Author> authors;
public Integer getId() {
return id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public Set<Author> getAuthors() {
return authors;
}
public void setAuthors(Set<Author> authors) {
this.authors = authors;
}
}
package hello;
import org.springframework.data.repository.CrudRepository;
public interface BookRepository extends CrudRepository<Book, Integer> {
}
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:2.0.5.RELEASE")
}
}
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
bootJar {
baseName = 'gs-serving-web-content'
version = '0.1.0'
}
repositories {
mavenCentral()
}
sourceCompatibility = 1.8
targetCompatibility = 1.8
dependencies {
compile("org.springframework.boot:spring-boot-starter-web")
compile("org.springframework.boot:spring-boot-starter-thymeleaf")
compile("org.springframework.boot:spring-boot-devtools")
// JPA Data (We are going to use Repositories, Entities, Hibernate, etc...)
compile 'org.springframework.boot:spring-boot-starter-data-jpa'
// Use MySQL Connector-J
compile 'mysql:mysql-connector-java'
// Immutable
compile group: 'com.google.guava', name: 'guava', version: '27.0-jre'
// compile 'com.fasterxml.jackson.core:jackson-databind:2.9.4'
testCompile("junit:junit")
}
package hello;
import com.fasterxml.jackson.annotation.ObjectIdGenerator.IdKey;
import com.fasterxml.jackson.annotation.ObjectIdResolver;
import com.fasterxml.jackson.annotation.SimpleObjectIdResolver;
import java.util.HashMap;
public class DedupingObjectIdResolver extends SimpleObjectIdResolver {
@Override
public void bindItem(IdKey id, Object ob) {
if (_items == null) {
_items = new HashMap<>();
}
_items.put(id, ob);
}
@Override
public ObjectIdResolver newForDeserialization(Object context) {
return new DedupingObjectIdResolver();
}
}
package hello;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Controller
@RequestMapping("/library")
public class LibraryController {
private final BookRepository bookRepository;
private final AuthorRepository authorRepository;
@Autowired
public LibraryController(BookRepository bookRepository, AuthorRepository authorRepository) {
this.bookRepository = bookRepository;
this.authorRepository = authorRepository;
}
@GetMapping("/book")
@ResponseBody
public Iterable<Book> getAllBooks() {
return bookRepository.findAll();
}
@PostMapping("/book")
@ResponseBody
public Book addBook(@RequestBody Book book) {
authorRepository.saveAll(book.getAuthors());
bookRepository.save(book);
return book;
}
@PostMapping("/books")
@ResponseBody
public Set<Book> addBooks(@RequestBody Set<Book> books) {
for (Book book : books) {
addBook(book);
}
return books;
}
@GetMapping("/author")
@ResponseBody
public Iterable<Author> getAllAuthors() {
return authorRepository.findAll();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment