001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.petstore.service;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.util.*;
022import java.util.concurrent.atomic.*;
023import java.util.function.*;
024
025import org.apache.juneau.petstore.dto.*;
026
027/**
028 * The staging area for pet-detail inline edits: edits are staged here instead of mutating the pet, then applied
029 * (perRow, with retry on failure) or discarded (aggregate) from the Pending changes page.
030 *
031 * <p>
032 * Obtained from {@link PetStore#pendingChanges()}.  All methods are synchronized; the store is a demo, not a
033 * transactional system.
034 *
035 * <h5 class='section'>Example:</h5>
036 * <p class='bjava'>
037 *    PendingChanges <jv>pc</jv> = <jv>store</jv>.pendingChanges();
038 *    PendingChange <jv>c</jv> = <jv>pc</jv>.stage(1, <js>"price"</js>, <js>"42.50"</js>, <js>"alice"</js>);
039 *    <jv>pc</jv>.stage(1, <js>"status"</js>, <js>"SOLD"</js>, <js>"alice"</js>);
040 *    <jv>pc</jv>.apply(<jv>c</jv>.getId(), <js>"console:alice"</js>);          <jc>// pet 1 now costs 42.50</jc>
041 *    PendingChanges.DiscardOutcome <jv>out</jv> = <jv>pc</jv>.discard(List.<jsm>of</jsm>(<js>"7"</js>, <js>"8"</js>), <js>"console:alice"</js>);
042 * </p>
043 */
044public class PendingChanges {
045
046   /**
047    * Result of an aggregate discard, mapped 1:1 onto the console's {@code BulkResult} by {@code ChangesRest}.
048    *
049    * @param succeeded Discarded change ids.
050    * @param notFound Ids that name no change (including unparseable ids).
051    * @param failed Ids that exist but could not be discarded, with the reason.
052    */
053   public record DiscardOutcome(List<String> succeeded, List<String> notFound, List<Failure> failed) {}
054
055   /**
056    * One failed id in a {@link DiscardOutcome}.
057    *
058    * @param id The change id.
059    * @param message Why it failed.
060    */
061   public record Failure(String id, String message) {}
062
063   private static final String PRICE_MSG = "Price '%s' must be a non-negative number";
064
065   /** Stageable field names, in the order reported by the "cannot be staged" message. */
066   private static final List<String> FIELDS = List.of("name", "price", "species", "status", "tags");
067
068   /** Per-field old-value rendering, validation and write. */
069   private enum StageField {
070      NAME("name", Pet::getName, PendingChanges::validateName, Pet::setName),
071      PRICE("price", p -> String.valueOf(p.getPrice()), PendingChanges::validatePrice, (p, v) -> p.setPrice(Float.parseFloat(v))),
072      SPECIES("species", p -> p.getSpecies() == null ? "" : p.getSpecies().name(), PendingChanges::validateSpecies, (p, v) -> p.setSpecies(Species.valueOf(v))),
073      STATUS("status", p -> p.getStatus() == null ? "" : p.getStatus().name(), PendingChanges::validateStatus, (p, v) -> p.setStatus(PetStatus.valueOf(v))),
074      TAGS("tags", p -> p.getTags() == null ? "" : String.join(",", p.getTags()), PendingChanges::parseTags, (p, v) -> p.setTags(parseTags(v)));
075
076      final String fieldName;
077      final Function<Pet,String> oldValue;
078      final Consumer<String> validator;
079      final BiConsumer<Pet,String> writer;
080
081      StageField(String fieldName, Function<Pet,String> oldValue, Consumer<String> validator, BiConsumer<Pet,String> writer) {
082         this.fieldName = fieldName;
083         this.oldValue = oldValue;
084         this.validator = validator;
085         this.writer = writer;
086      }
087
088      static StageField of(String fieldName) {
089         for (var f : values())
090            if (f.fieldName.equals(fieldName))
091               return f;
092         return null;
093      }
094   }
095
096   private final PetStore store;
097   private final Map<Long,PendingChange> changes = new LinkedHashMap<>();
098   private final AtomicLong nextId = new AtomicLong();
099
100   PendingChanges(PetStore store) {
101      this.store = store;
102   }
103
104   /**
105    * Stages an edit without touching the pet.
106    *
107    * @param petId The pet id.
108    * @param field One of {@code "name"}, {@code "price"}, {@code "species"}, {@code "status"} or {@code "tags"}
109    * (tags is a comma-separated list; blank clears).
110    * @param newValue The new value (string form).
111    * @param author Who staged it.
112    * @return The new pending change.
113    * @throws PetstoreNotFoundException If the pet does not exist.
114    * @throws IllegalArgumentException If the field is not stageable or the value is invalid.
115    */
116   public synchronized PendingChange stage(long petId, String field, String newValue, String author) {
117      var pet = store.getPet(petId);
118      if (pet == null)
119         throw new PetstoreNotFoundException(f("Unknown pet '%s'", petId));
120      var sf = StageField.of(field);
121      if (sf == null)
122         throw iaex("Field '%s' cannot be staged; use one of: %s", field, String.join(", ", FIELDS));
123      sf.validator.accept(newValue);
124      var old = sf.oldValue.apply(pet);
125      var c = new PendingChange().setId(nextId.incrementAndGet()).setPetId(petId).setField(field)
126         .setOldValue(old).setNewValue(newValue).setAuthor(author)
127         .setSummary(f("%s: %s '%s' -> '%s'", pet.getName(), field, old, newValue));
128      changes.put(c.getId(), c);
129      return c;
130   }
131
132   /** @return All changes, staging order.  Immutable snapshot. */
133   public synchronized List<PendingChange> list() {
134      return List.copyOf(changes.values());
135   }
136
137   /** @return Only {@link PendingChange.State#PENDING} changes, staging order.  Immutable snapshot. */
138   public synchronized List<PendingChange> pending() {
139      return changes.values().stream().filter(x -> x.getState() == PendingChange.State.PENDING).toList();
140   }
141
142   /**
143    * Returns one change.
144    *
145    * @param id The change id.
146    * @return The change.
147    * @throws PetstoreNotFoundException If it does not exist.
148    */
149   public synchronized PendingChange get(long id) {
150      var c = changes.get(id);
151      if (c == null)
152         throw new PetstoreNotFoundException(f("Unknown change '%s'", id));
153      return c;
154   }
155
156   /**
157    * Writes a pending change to its pet.  If the pet is gone, the change becomes {@code FAILED} instead.
158    *
159    * @param id The change id.
160    * @param actor The audit actor.
161    * @return The change, now {@code APPLIED} or {@code FAILED}.
162    * @throws PetstoreNotFoundException If the change does not exist.
163    * @throws PetstoreConflictException If the change is not pending.
164    */
165   public synchronized PendingChange apply(long id, String actor) {
166      var c = get(id);
167      if (c.getState() != PendingChange.State.PENDING)
168         throw new PetstoreConflictException("Change '%s' is not pending", id);
169      return write(c, actor);
170   }
171
172   /**
173    * Re-applies a failed change.
174    *
175    * @param id The change id.
176    * @param actor The audit actor.
177    * @return The change, now {@code APPLIED} or still {@code FAILED}.
178    * @throws PetstoreNotFoundException If the change does not exist.
179    * @throws PetstoreConflictException If the change has not failed.
180    */
181   public synchronized PendingChange retry(long id, String actor) {
182      var c = get(id);
183      if (c.getState() != PendingChange.State.FAILED)
184         throw new PetstoreConflictException("Change '%s' has not failed", id);
185      return write(c, actor);
186   }
187
188   /**
189    * Discards pending changes in one aggregate call.
190    *
191    * @param ids The change ids (string form, as sent by the bulk action).
192    * @param actor The audit actor.
193    * @return The per-id outcome.
194    */
195   public synchronized DiscardOutcome discard(Collection<String> ids, String actor) {
196      var ok = new ArrayList<String>();
197      var missing = new ArrayList<String>();
198      var failed = new ArrayList<Failure>();
199      for (var sid : ids) {
200         Long id = parse(sid);
201         var c = id == null ? null : changes.get(id);
202         if (c == null)
203            missing.add(sid);
204         else if (c.getState() != PendingChange.State.PENDING)
205            failed.add(new Failure(sid, f("Change '%s' is not pending", sid)));
206         else {
207            changes.remove(id);
208            store.recordAudit(actor, "Change", sid, "DISCARD", c.getSummary());
209            ok.add(sid);
210         }
211      }
212      return new DiscardOutcome(List.copyOf(ok), List.copyOf(missing), List.copyOf(failed));
213   }
214
215   private PendingChange write(PendingChange c, String actor) {
216      var pet = store.getPet(c.getPetId());
217      if (pet == null)
218         return c.setState(PendingChange.State.FAILED).setMessage(f("Unknown pet '%s'", c.getPetId()));
219      StageField.of(c.getField()).writer.accept(pet, c.getNewValue());
220      store.updatePet(pet, actor);
221      return c.setState(PendingChange.State.APPLIED).setMessage(null);
222   }
223
224   private static void validateName(String value) {
225      if (ib(value))
226         throw iaex("Name must not be blank");
227   }
228
229   private static void validatePrice(String value) {
230      if (value == null)
231         throw iaex(PRICE_MSG, value);
232      float p;
233      try {
234         p = Float.parseFloat(value);
235      } catch (NumberFormatException e) {
236         throw iaex(PRICE_MSG, value);
237      }
238      if (p < 0 || Float.isNaN(p) || Float.isInfinite(p))
239         throw iaex(PRICE_MSG, value);
240   }
241
242   private static void validateSpecies(String value) {
243      for (var x : Species.values())
244         if (x.name().equals(value))
245            return;
246      throw iaex("Species '%s' must be one of: %s", value, enumNames(Species.values()));
247   }
248
249   private static void validateStatus(String value) {
250      for (var x : PetStatus.values())
251         if (x.name().equals(value))
252            return;
253      throw iaex("Status '%s' must be one of: %s", value, enumNames(PetStatus.values()));
254   }
255
256   private static String enumNames(Enum<?>[] values) {
257      return String.join(", ", Arrays.stream(values).map(Enum::name).toList());
258   }
259
260   /** Parses a comma-separated tag list; blank means empty; validates as it parses. */
261   private static List<String> parseTags(String value) {
262      if (value == null)
263         throw iaex("Tags must not be null");
264      if (value.isBlank())
265         return new ArrayList<>();
266      var out = new ArrayList<String>();
267      for (var t : value.split(",", -1)) {
268         var x = t.trim();
269         if (x.isEmpty())
270            throw iaex("Tags '%s' must not contain empty entries", value);
271         out.add(x);
272      }
273      return out;
274   }
275
276   private static Long parse(String s) {
277      try {
278         return Long.valueOf(s);
279      } catch (NumberFormatException e) {
280         return null;
281      }
282   }
283}