diff --git a/bookstore/src/main/java/com/fjordtek/bookstore/BookstoreApplication.java b/bookstore/src/main/java/com/fjordtek/bookstore/BookstoreApplication.java index aa3f1a7..5e4b92e 100644 --- a/bookstore/src/main/java/com/fjordtek/bookstore/BookstoreApplication.java +++ b/bookstore/src/main/java/com/fjordtek/bookstore/BookstoreApplication.java @@ -12,6 +12,8 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; import org.springframework.context.annotation.Bean; +import com.fjordtek.bookstore.model.Author; +import com.fjordtek.bookstore.model.AuthorRepository; import com.fjordtek.bookstore.model.Book; import com.fjordtek.bookstore.model.BookRepository; import com.fjordtek.bookstore.model.Category; @@ -26,7 +28,11 @@ public class BookstoreApplication extends SpringBootServletInitializer { } @Bean - public CommandLineRunner bookDatabaseRunner(BookRepository bookRepository, CategoryRepository categoryRepository) { + public CommandLineRunner bookDatabaseRunner( + BookRepository bookRepository, + CategoryRepository categoryRepository, + AuthorRepository authorRepository + ) { return (args) -> { @@ -36,11 +42,16 @@ public class BookstoreApplication extends SpringBootServletInitializer { categoryRepository.save(new Category("Fantasy")); categoryRepository.save(new Category("Sci-Fi")); + authorRepository.save(new Author("Angela","Carter")); + authorRepository.save(new Author("Andrzej","Sapkowski")); + commonLogger.info("Add new sample books to database"); bookRepository.save(new Book( "Bloody Chamber", - "Angela Carter", + authorRepository.findByFirstNameIgnoreCaseContainingAndLastNameIgnoreCaseContaining( + "Angela","Carter" + ).get(0), 1979, "1231231-12", new BigDecimal("18.00"), @@ -48,7 +59,9 @@ public class BookstoreApplication extends SpringBootServletInitializer { )); bookRepository.save(new Book( "The Witcher: The Lady of the Lake", - "Andrzej Sapkowski", + authorRepository.findByFirstNameIgnoreCaseContainingAndLastNameIgnoreCaseContaining( + "Andrzej","Sapkowski" + ).get(0), 1999, "3213221-3", new BigDecimal("19.99"), @@ -60,6 +73,10 @@ public class BookstoreApplication extends SpringBootServletInitializer { for (Category category : categoryRepository.findAll()) { commonLogger.info(category.toString()); } + commonLogger.info("Sample authors in the database"); + for (Author author : authorRepository.findAll()) { + commonLogger.info(author.toString()); + } commonLogger.info("Sample books in the database"); for (Book book : bookRepository.findAll()) { commonLogger.info(book.toString()); diff --git a/bookstore/src/main/java/com/fjordtek/bookstore/model/Author.java b/bookstore/src/main/java/com/fjordtek/bookstore/model/Author.java new file mode 100644 index 0000000..92794a7 --- /dev/null +++ b/bookstore/src/main/java/com/fjordtek/bookstore/model/Author.java @@ -0,0 +1,147 @@ +package com.fjordtek.bookstore.model; + +import java.util.List; + +import javax.persistence.CascadeType; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.OneToMany; +import javax.persistence.SequenceGenerator; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +@Entity +public class Author { + + private static final int strMin = 2; + private static final int strMax = 100; + // We format length check in Size annotation, not here + private static final String regexCommon = "^[a-zA-Z0-9\\-:\\s]*$"; + + //////////////////// + // Primary key value in database + + @Id + @GeneratedValue( + strategy = GenerationType.IDENTITY, + generator = "authorIdGenerator" + ) + @SequenceGenerator( + name = "authorIdGenerator", + sequenceName = "authorIdSequence" + ) + @JsonIgnore + private Long id; + + ////////// + @Column(name = "firstname", nullable = false) + @Size( + min = strMin, max = strMax, + message = "First name length must be " + strMin + "-" + strMax + " characters" + ) + @NotBlank( + message = "Fill the first name form" + ) + @Pattern( + regexp = regexCommon, + message = "Invalid characters" + ) + private String firstName; + + ////////// + @Column(name = "lastname", nullable = false) + @Size( + min = strMin, max = strMax, + message = "Last name length must be " + strMin + "-" + strMax + " characters" + ) + @NotBlank( + message = "Fill the first name form" + ) + @Pattern( + regexp = regexCommon, + message = "Invalid characters" + ) + private String lastName; + + // Omit from Jackson JSON serialization + //@JsonBackReference(value = "books") + + @JsonIgnore + @OneToMany( + mappedBy = "author", + // We consider EAGER FetchType for updatable tables, i.e. when adding new author + fetch = FetchType.EAGER, + cascade = CascadeType.ALL, + targetEntity = Book.class + ) + private List books; + + //////////////////// + // Attribute setters + + public void setId(Long id) { + this.id = id; + } + + public void setFirstName(String firstName) { + // Delete leading & trailing whitespaces (typos from user) + this.firstName = firstName.trim(); + } + + public void setLastName(String lastName) { + // Delete leading & trailing whitespaces (typos from user) + this.lastName = lastName.trim(); + } + + public void setBooks(List books) { + this.books = books; + } + + //////////////////// + // Attribute getters + + public Long getId() { + return id; + } + + public String getFirstName() { + return firstName; + } + + public String getLastName() { + return lastName; + } + + public List getBooks() { + return books; + } + + //////////////////// + // Class constructors + + public Author() {} + + public Author(String firstName, String lastName) { + // super(); + this.firstName = firstName; + this.lastName = lastName; + } + + //////////////////// + // Class overrides + + @Override + public String toString() { + return "[" + "id: " + this.id + ", " + + "firstname: " + this.firstName + ", " + + "lastname: " + this.lastName + "]"; + } + +} \ No newline at end of file diff --git a/bookstore/src/main/java/com/fjordtek/bookstore/model/AuthorJsonSerializer.java b/bookstore/src/main/java/com/fjordtek/bookstore/model/AuthorJsonSerializer.java new file mode 100644 index 0000000..ab227cf --- /dev/null +++ b/bookstore/src/main/java/com/fjordtek/bookstore/model/AuthorJsonSerializer.java @@ -0,0 +1,31 @@ +package com.fjordtek.bookstore.model; + +import java.io.IOException; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; + +public class AuthorJsonSerializer extends StdSerializer { + private static final long serialVersionUID = 5233819344225306443L; + + public AuthorJsonSerializer() { + this(null); + } + + public AuthorJsonSerializer(Class jd) { + super(jd); + } + + @Override + public void serialize(Author author, JsonGenerator gen, SerializerProvider provider) + throws IOException { + gen.writeStartObject(); + // Author class Id has JsonIgnore annotation + //gen.writeFieldId(author.getId()); + gen.writeStringField("firstname", author.getFirstName()); + gen.writeStringField("lastname", author.getLastName()); + gen.writeEndObject(); + } + +} \ No newline at end of file diff --git a/bookstore/src/main/java/com/fjordtek/bookstore/model/AuthorRepository.java b/bookstore/src/main/java/com/fjordtek/bookstore/model/AuthorRepository.java new file mode 100644 index 0000000..6295bbc --- /dev/null +++ b/bookstore/src/main/java/com/fjordtek/bookstore/model/AuthorRepository.java @@ -0,0 +1,22 @@ +package com.fjordtek.bookstore.model; + +import java.util.List; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.data.rest.core.annotation.RepositoryRestResource; +import org.springframework.data.rest.core.annotation.RestResource; + +@RepositoryRestResource( + path = "authors", + itemResourceRel = "authors", + exported = true + ) +public interface AuthorRepository extends CrudRepository { + + @RestResource(path = "author", rel = "author") + public List findByFirstNameIgnoreCaseContainingAndLastNameIgnoreCaseContaining( + @Param("firstname") String firstName, @Param("lastname") String lastName + ); + +} \ No newline at end of file diff --git a/bookstore/src/main/java/com/fjordtek/bookstore/model/Book.java b/bookstore/src/main/java/com/fjordtek/bookstore/model/Book.java index 33395a9..70efa4a 100644 --- a/bookstore/src/main/java/com/fjordtek/bookstore/model/Book.java +++ b/bookstore/src/main/java/com/fjordtek/bookstore/model/Book.java @@ -98,19 +98,24 @@ public class Book { private String title; ////////// - @Column(nullable = false) - @Size( - min = strMin, max = strMax, - message = "Author length must be " + strMin + "-" + strMax + " characters" - ) - @NotBlank( - message = "Fill the book author form" - ) - @Pattern( - regexp = regexCommon, - message = "Invalid characters" + // If category is null, we do not print it in JSON output. + @JsonUnwrapped + + /* + * There are many ways to filter which category fields we want to JSON output. + * Using a custom JSON serializer is one of them. + */ + @JsonSerialize(using = AuthorJsonSerializer.class) + @ManyToOne( + fetch = FetchType.EAGER, + optional = true, + targetEntity = Author.class ) - private String author; + @JoinColumn( + name = "author_id", + nullable = true + ) + private Author author; ////////// // TODO: Prefer Timestamp data type @@ -175,8 +180,9 @@ public class Book { */ @JsonSerialize(using = CategoryJsonSerializer.class) @ManyToOne( - fetch = FetchType.EAGER, - optional = true + fetch = FetchType.EAGER, + optional = true, + targetEntity = Category.class ) @JoinColumn( name = "category_id", @@ -198,7 +204,7 @@ public class Book { this.title = title; } - public void setAuthor(String author) { + public void setAuthor(Author author) { this.author = author; } @@ -231,7 +237,7 @@ public class Book { return title; } - public String getAuthor() { + public Author getAuthor() { return author; } @@ -256,7 +262,7 @@ public class Book { public Book() {} - public Book(String title, String author, int year, String isbn, BigDecimal price, Category category) { + public Book(String title, Author author, int year, String isbn, BigDecimal price, Category category) { // super(); this.title = title; this.author = author; diff --git a/bookstore/src/main/java/com/fjordtek/bookstore/model/Category.java b/bookstore/src/main/java/com/fjordtek/bookstore/model/Category.java index 0ef90b9..7c3629c 100644 --- a/bookstore/src/main/java/com/fjordtek/bookstore/model/Category.java +++ b/bookstore/src/main/java/com/fjordtek/bookstore/model/Category.java @@ -43,8 +43,8 @@ public class Category { @OneToMany( mappedBy = "category", fetch = FetchType.LAZY, - cascade = CascadeType.ALL - //targetEntity = Book.class + cascade = CascadeType.ALL, + targetEntity = Book.class ) private List books; diff --git a/bookstore/src/main/java/com/fjordtek/bookstore/web/BookController.java b/bookstore/src/main/java/com/fjordtek/bookstore/web/BookController.java index 6a2358e..9f331d9 100644 --- a/bookstore/src/main/java/com/fjordtek/bookstore/web/BookController.java +++ b/bookstore/src/main/java/com/fjordtek/bookstore/web/BookController.java @@ -25,6 +25,8 @@ import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; +import com.fjordtek.bookstore.model.Author; +import com.fjordtek.bookstore.model.AuthorRepository; import com.fjordtek.bookstore.model.Book; import com.fjordtek.bookstore.model.BookRepository; import com.fjordtek.bookstore.model.CategoryRepository; @@ -41,10 +43,13 @@ public class BookController { } @Autowired - private BookRepository bookRepository; + private CategoryRepository categoryRepository; @Autowired - private CategoryRepository categoryRepository; + private AuthorRepository authorRepository; + + @Autowired + private BookRepository bookRepository; private static final String RestJSONPageView = "json"; private static final String RestAPIRefPageView = "apiref"; @@ -76,6 +81,38 @@ public class BookController { // Security implications of adding these all controller-wide? dataModel.addAllAttributes(globalModelMap); dataModel.addAttribute("categories", categoryRepository.findAll()); + dataModel.addAttribute("authors", authorRepository.findAll()); + } + + ////////////////////////////// + // Private methods + + private void detectAndSaveBookAuthor(Book book) { + /* + * Find an author from the current AUTHOR table by his/her first and last name. + * In CrudRepository, if Id attribute is not found, it is stored + * as a new row value. Therefore, it's crucial to identify whether row value already + * exists in AUTHOR table. + */ + + try { + Author authorI = authorRepository.findByFirstNameIgnoreCaseContainingAndLastNameIgnoreCaseContaining( + book.getAuthor().getFirstName(),book.getAuthor().getLastName()) + .get(0); + + /* + * When author is found, use it's Id attribute for book's author Id... + */ + book.getAuthor().setId(authorI.getId()); + + /* + * ...Otherwise, consider this a new author and store it appropriately. + * Actually, when author is not found, we get IndexOutOfBoundsException. + */ + } catch (IndexOutOfBoundsException e) { + authorRepository.save(book.getAuthor()); + } + } ////////////////////////////// @@ -144,8 +181,10 @@ public class BookController { httpServerLogger.log(requestData, responseData); + detectAndSaveBookAuthor(book); bookRepository.save(book); + return "redirect:" + bookListPageView; } @@ -231,6 +270,8 @@ public class BookController { return bookEditPageView; } + + detectAndSaveBookAuthor(book); bookRepository.save(book); httpServerLogger.log(requestData, responseData);