OpenNTF / org.openntf.xsp.jakartaee

XPages Jakarta EE support libraries
Apache License 2.0
21 stars 5 forks source link

@JsonbTransient affects loading? #513

Closed monstermichl closed 2 months ago

monstermichl commented 4 months ago

Hey Jesse, I just found something and I'm not sure if this is intended. I have a property in my entity class which is a list of strings. I can put an element into this list and it get's properly saved. However, if it has just one entry, it's stored as Text, not as Text List. The next time I try to retrieve the value the loaded list is empty. So basically if I always add just one element, I can never add a second, third, and so on because the loaded list is always empty.

EDIT: It seems to be a different problem. I realized, I used List\<String> in the past and it always worked. I will keep this issue open for now to check if it's on my side.

monstermichl commented 4 months ago

I found the issue and I think it's really important. I don't know if this is relevant, by my list is used to store log data and each entry is a JSON string. If I mark the getter getLog() with \@JsonbTransient, the data doesn't get loaded, if I remove the annotation, everything works as expected. So I think using it could actually be a problem (https://github.com/OpenNTF/org.openntf.xsp.jakartaee/issues/510).

monstermichl commented 4 months ago

Could it be, that marking the method in general could be a problem? As I wrote before, annotating the method with \@JsonbTransient prevents loading but also loading doesn't work anymore if I don't annotate it and rename it to retrieveLog (which I wanted to do to hide it from the JSON output). However, this could be related to the List type or even because the list contains JSON strings because at first glance at least it seems to work properly with String fields for example.

jesse-gallagher commented 4 months ago

Huh! I'll definitely have to investigate this when I get a chance.

jesse-gallagher commented 4 months ago

I'm trying to reproduce this in a test suite, but without success so far. I have:

The methods look like:

@JsonbTransient
public List<String> getJsonTransientField() {
    return jsonTransientField;
}
public void setJsonTransientField(List<String> jsonTransientField) {
    this.jsonTransientField = jsonTransientField;
}
public List<String> getAlternateMethodStorage() {
    return jsonTransientField;
}

@JsonbTransient
public List<String> getJsonTransientField2() {
    return jsonTransientField2;
}
public void setJsonTransientField2(List<String> jsonTransientField2) {
    this.jsonTransientField2 = jsonTransientField2;
}
public List<String> getAlternateMethodStorage2() {
    return jsonTransientField2;
}

When I create and then fetch docs via REST, the results look like what I'd expect: the "real" properties aren't in the JSON because their getters are marked JSON transient, but the other properties based on the getters are there.

I'm guessing you're doing something different than my test - could you let me know where mine differs, so I can hopefully better track it down?

monstermichl commented 4 months ago

Hey Jesse. Yes, you're right, hiding the fields from the JSON works that way. However, I'm having problems attaching new data to the list when storing it via a repository. I'm not completely sure why. I'm using the save method with the computeWithForm parameter set to true (I even added a field to the form with Allow multiple values set but it didn't help).

image

It works if I do this

public List<String> getLog() {
   return this._makeSureIsList(this._getLog());
}

But with the following two examples it doesn't work anymore. Everytime I save, the only entry in the list is the current one.

@JsonbTransient
public List<String> getLog() {
   return this._makeSureIsList(this._getLog());
}
public List<String> retrieveLog() {
   return this._makeSureIsList(this._getLog());
}

The data I'm storing is a list but each entry is a JSON object (relevant?). It might also help to know, that I have a base class for my entities. Here's a stripped down version.

public abstract class DominoDocumentEntityBase {

    @JsonbTransient
    public List<String> getLog() {
        return this._makeSureIsList(this._getLog());
    }

    public void setLog(List<String> log) {
        if (log != null) {
            this._setLog(log);
        }
    }

    protected abstract List<String> _getLog();
    protected abstract void _setLog(List<String> entries);

    private <T> List<T> _makeSureIsList(List<T> list) {
        return (list != null) ? list : new ArrayList<>();
    }
}

The deriving class needs to implement the _getLog and _setLog methods since it's not possible by now to define the common properties in the base class with the Column annotation since it's not marked with Entity annotation.

@Entity("test")
public class TestEntity extends DominoDocumentEntityBase {

    @Column("log")
    private List<String> _log;

    @Override
    protected List<String> _getLog() {
        return this._log;
    }

    @Override
    protected void _setLog(List<String> entries) {
        this._log = entries;
    }
}

Hope this helps.

BR Michel

monstermichl commented 3 months ago

Hey Jesse, do you have any news on that?

jesse-gallagher commented 3 months ago

Not yet - when I've gone to reproduce it, I haven't been successful, but it's on my list to attempt again when I can.

jesse-gallagher commented 2 months ago

Hmm, I'm trying to get my test setup to more-closely match what you have, but I haven't seen the problem yet. Specifically, what I've done is:

Form

I created a form named "LogDoc" that has two fields: a normal text field named "name" and a multi-value text field named "log".

Base entity

I created an abstract class like yours:

public abstract class DominoDocumentEntityBase {
    @JsonbTransient
    public List<String> getLog() {
        return this._makeSureIsList(this._getLog());
    }

    public void setLog(List<String> log) {
        if (log != null) {
            this._setLog(log);
        }
    }

    protected abstract List<String> _getLog();
    protected abstract void _setLog(List<String> entries);

    private <T> List<T> _makeSureIsList(List<T> list) {
        return (list != null) ? list : new ArrayList<>();
    }
}

Entity class

Then, I have an entity that extends it:

@Entity("LogDoc")
public class LogDoc extends DominoDocumentEntityBase {
    public interface Repository extends DominoRepository<LogDoc, String> {

    }

    @Id
    private String id;

    @Column("log")
    private List<String> log;

    @Column("name")
    private String name;

    public String getId() { return id; }
    public void setId(String id) { this.id = id; }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    @Override
    protected List<String> _getLog() { return this.log; }
    @Override
    protected void _setLog(List<String> entries) { this.log = entries; }
}

REST Service

I wrote a REST service to provide create/read/patch capabilities:

@Path("nosql/logdoc")
public class NoSQLLogDocs {
    @Inject
    private LogDoc.Repository logDocs;

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public LogDoc create(@Valid LogDoc logDoc) {
        return logDocs.save(logDoc, true);
    }

    @Path("{unid}")
    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public LogDoc get(@PathParam("unid") String unid) {
        return logDocs.findById(unid)
            .orElseThrow(() -> new NotFoundException());
    }

    @Path("{unid}")
    @PATCH
    @Consumes(MediaType.TEXT_PLAIN)
    @Produces(MediaType.APPLICATION_JSON)
    public LogDoc patch(@PathParam("unid") String unid, String newName) {
        LogDoc doc = logDocs.findById(unid)
                .orElseThrow(() -> new NotFoundException());
        doc.setName(newName);
        return logDocs.save(doc, true);
    }
}

Use

Then, I POST this to the first endpoint as application/json:

{
  "name": "Some Log Doc",
  "log": [
    "{\"foo\":\"bar\"}",
    "{\"bar\":\"baz\"}"
  ]
}

Afterward, I PATCH this to the last endpoint:

I am the patched name

When I do that, each REST result is a JSON object with id and name, which makes sense since getLog is transient while _getLog is not a bean-format method. I can also see the "log" value remaining a two-entry text item in the document after the original POST and remaining after the PATCH.

I also tried it with modifying the PATCH method to itself do some programmatic addition to the log field:

@Path("{unid}")
@PATCH
@Consumes(MediaType.TEXT_PLAIN)
@Produces(MediaType.APPLICATION_JSON)
public LogDoc patch(@PathParam("unid") String unid, String newName) {
    LogDoc doc = logDocs.findById(unid)
            .orElseThrow(() -> new NotFoundException());
    doc.setName(newName);
    List<String> log = new ArrayList<>(doc.getLog());
    log.add("{\"foo\":\"added at " + System.currentTimeMillis() + "\"}");
    doc.setLog(log);
    return logDocs.save(doc, true);
}

That works as I'd expect: each PATCH adds a new entry to the text list.

I may be missing some complicating aspects, though - let me know if this doesn't look like a good approximation of what you're doing.

monstermichl commented 2 months ago

Hey Jesse, I set up a new project to test it in an isolated environment and I'm sorry to tell you, but it was a problem in my code 😬 JsonbTransient works fine. I just didn't replace the log that was received via REST with the data that was already present in the database. Therefore it worked when the log was sent to the frontend and sent back on an update, but not if the getter's name was e.g. retrieveLog or marked with the JsonbTransient annotation. Thanks for your effort, I hope you didn't invest too much time :/

jesse-gallagher commented 2 months ago

Don't sweat it - the nice thing is that even a false alarm generates more test data, and that's always a plus.

monstermichl commented 2 months ago

Haha that's optimism :D