5

Use Case: A user can CRUD multiple choice questions using a single page web application written in JavaScript.

  1. Creating a new question and adding some options all happens within the browser / Frontend (FE).
  2. The FE creates and uses temporary ids ("_1", "_2", ...) for both the question and all the options until the user clicks a save button.
  3. When saving the newly created question the FE sends a JSON containing the temporary ids to the backend
  4. As result the FE expects a 201 CREATED containing a map temporary id -> backend id to update its ids.
  5. The user decides to add another Option (which on the FE side uses a temporary id again)
  6. The user clicks save and the FE sends the updated question with a mixture of backend ids (for the question and the existing options) and a temporary id (for the newly created option)
  7. To update the id of the the newly created option, the FE expects the reponse to contain the mapping for this id.

How should we implement the counterpart for the last part (5-7 adding an option) on the backend side?

I try this, but I cannot get the child ids after persistence.

Entities

@Entity
public class Question {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @OneToMany(mappedBy = "config", fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Option> options = new ArrayList<>();
    // ...
}


@Entity
public class Option {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "question_id", nullable = false)
    private Question question;

    public Option(Long id, Config config) {
        this.id = id;
        this.question = question;
    }
    // ...
}

Controller

@RestController
@RequestMapping("/questions")
public class AdminQuestionsController {

    @Autowired
    private QuestionRepository questionRepo;

    @Autowired
    private OptionRepository optionRepo;

    @PutMapping("/{id}")
    @ResponseStatus(HttpStatus.OK)
    public QuestionDTO updateQuestion(@PathVariable("id") String id, @RequestBody QuestionDTO requestDTO) {
        Question question = questionRepo.findOneById(Long.parseLong(id));

        // will hold a mapping of the temporary id to the newly created Options.
        Map<String, Option> newOptions = new HashMap<>();

        // update the options        
        question.getOptions().clear();

        requestDTO.getOptions().stream()
            .map(o -> {
                try { // to find the existing option
                    Option theOption = question.getOptions().stream()
                            // try to find in given config
                            .filter(existing -> o.getId().equals(existing.getId()))
                            .findAny()
                            // fallback to db
                            .orElse(optionRepo.findOne(Long.parseLong(o.getId())));
                    if (null != theOption) {
                        return theOption;
                    }
                } catch (Exception e) {
                }
                // handle as new one by creating a new one with id=null
                Option newOption = new Option(null, config);
                newOptions.put(o.getId(), newOption);
                return newOption;
            })
            .forEach(o -> question.getOptions().add(o));

        question = questionRepo.save(question);

        // create the id mapping
        Map<String, String> idMap = new HashMap<>();
        for (Entry<String, Option> e : newOptions.entrySet()) {
            idMap.put(e.getKey(), e.getValue().getId());
            // PROBLEM: e.getValue().getId() is null 
        }

        return QuestionDTO result = QuestionDTO.from(question, idMap);
    }
}

In the controller I marked the Problem: e.getValue().getId() is null

How should such a controller create the idMap?

Stuck
  • 11,225
  • 11
  • 59
  • 104
  • can you please describe about Option constructor where you give param Config but you not using that and instead of it you using question, another point is that when you try to handle new Option then u again use config with null id, can you define what is config and how it use in your code – Devratna Jun 07 '18 at 07:56

3 Answers3

1

It would be best if you save each option individually and then save the generated Id on the map.

I did the test below and it works perfectly.

@Autowired
void printServiceInstance(QuestionRepository questions, OptionRepository options) {
    Question question = new Question();

    questions.save(question);

    question.add(new Option(-1L, question));
    question.add(new Option(-2L, question));
    question.add(new Option(-3L, question));
    question.add(new Option(-4L, question));

    Map<Long, Long> idMap = new HashMap<>();

    question.getOptions().stream()
            .filter(option -> option.getId() < 0)
            .forEach(option -> idMap.put(option.getId(), options.save(option).getId()));

    System.out.println(idMap);
}

Console out: {-1=2, -2=3, -3=4, -4=5}

UPDATED: Or will be a better code style if the front end just control de order of the options, and get the new ids based on the order of the unsaved options.

Option:

@Column(name = "order_num")
private Integer order;

public Option(Long id, Integer order, Question question) {
    this.id = id;
    this.question = question;
    this.order = order;
}

Update example:

@Autowired
void printServiceInstance(QuestionRepository questions, OptionRepository options) {
    Question question = new Question();

    Question merged = questions.save(question);

    merged.add(new Option(-1L, 1, merged));
    merged.add(new Option(-2L, 2, merged));
    merged.add(new Option(-3L, 3, merged));
    merged.add(new Option(-4L, 4, merged));

    questions.save(merged);

    System.out.println(questions.findById(merged.getId()).get().getOptions());//
}

Console out: [Option [id=2, order=1], Option [id=3, order=2], Option [id=4, order=3], Option [id=5, order=4]]

Note there is no need of a map to control the new ids, the front-end should know by get it by the order of the options.

  • Using the order does solve the problem. It is currently our workaround of which we want to get rid of, because it is implicit and hard to grasp for new developers. – Stuck Jun 11 '18 at 07:49
  • Yep, could be hard to grasp this option with order. So, and the first option I gave with map did not fit for your need? – Norberto Ritzmann Jun 11 '18 at 11:08
  • Saving each option individually provides the ids and helps mapping them, yes. In context of the question it is a solution. However, I simplified the question from our actual use case which is much more complex. In context of our actual problem we want to use cascading of a question to store much more information to prevent invalid system states. But your answers was the only one that actually provided solutions and I already gave the bounty to you :) Gratz ;) – Stuck Jun 11 '18 at 12:38
0

you can create additional field in both Question and Option class and marked as @Transient to make sure it's not persisted.

class Question {
   ....
   private String id; // actual data field

   @Transient
   private String tempId;

   // getter & setter
}

Initially when UI sends data, set tmpId and persist your Object. On successful operation, id will have actual id value. Now, let's create mapping (tmpId -> actualId).

Map<String, String> mapping = question.getOptions().stream()
    .collect(Collectors.toMap(Option::getTmpId, Option::getId, (first, second) -> second));

mapping.put(question.getTmpId(), question.getId());

As you want only newly created object, we can do that two ways. Either add filter while creating mapping or remove later.

As mentioned, after first save, UI will update tmpId with actual Id and on next update, you will get a mix (actual for already saved, and tempId for newly created). In case of already saved, tmpId and actualId will be same.

mapping.entrySet().removeIf(entry -> entry.getKey().equals(entry.getValue()));

Regarding your controller code, you are clearing all existing option before adding a new option. If you are getting Question Object which has id (actual) field already populated, you can persist it directly. it will not affect anything. Additional if it has some change in that, that will be persisted.

Regarding your controller code, As you are clearing

question.getOptions().clear();

After this, you can simply add new options.

question.setOptions(requestDTO.getOptions());

question = questionRepo.save(question);

I hope it helps now.

lucid
  • 2,722
  • 1
  • 12
  • 24
  • "On successful operation, id will have actual id value" this is not true when merging an existing question with existing options and newly added options (steps 6 and 7) since the EM does create new instances of the Options and hence the transient fields are NOT SET. – Stuck Jun 11 '18 at 08:10
  • @Stuck transient field supposed to set by FE. when they adding new option, they will have tmpId (set like "_1" or "_2") and while saving, you can do question.getOptions().addAll(newlyAddedOptionsList). Transient will not be saved by EM. EM will update ActualId ("id") which is backend id. – lucid Jun 11 '18 at 09:27
  • it only works if all options are new, but not when merging existing and new options since the merge function operates differently than an initial save operation. see for example https://stackoverflow.com/a/50707702/386201 – Stuck Jun 11 '18 at 09:40
-2

So, you need to distinct FE-generated ID from BE-generated ? You can

  1. use negative ID on FE-generated, positive on BE
  2. choose special prefix/suffix for FE-generated("fe_1", "fe_2", ...)
  3. keep in Session list of already mapped IDs (server-side)
  4. keep list of FE-generated ID and send it with data on POST (client-side)

In any case, beware of collisions when mixing two generators of ID.

evg345
  • 107
  • 5