Spring Data Rest — поле PATCH Postgres jsonb

Краткая версия: Как исправить объект JSON, содержащийся в поле Postgres jsonb, с помощью метода Spring Data Rest PATCH?

Вот длинная версия, пожалуйста, рассмотрите следующую сущность:

@Entity
@Table(name = "examples")
public class Example {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    private String jsonobject;

    @JsonRawValue
    public String getJsonobject() {
        return jsonobject == null ? null : jsonobject;
    }

    public void setJsonobject(JsonNode jsonobject) {
        this.jsonobject = jsonobject == null ? null : jsonobject.toString();
    }
}

jsonobject имеет тип Postgres jsonb. Эти геттеры/установщики являются способом сериализации/десериализации для Spring Data Rest, упомянутого здесь. Мы также попытались присвоить полю собственный тип, как указано в этих ответов. .

Наша цель — исправить объект JSON, содержащийся в этом поле, с помощью Spring Data Rest.

Например:

GET /examples/1
{
    "id": 1,
    "jsonobject": {
         "foo": {"bar": "Hello"},
         "baz": 2
    }
}

PATCH /examples/1
{
    "jsonobject": {
        "foo": {"bar": "Welcome"}
    }
}

Ожидаемый результат:

GET /examples/1
{
    "id": 1,
    "jsonobject": {
         "foo": {"bar": "Welcome"},
         "baz": 2
    }
}

Текущий выход:

GET /examples/1
{
    "id": 1,
    "jsonobject": {
         "foo": {"bar": "Welcome"}
    }
}

Spring Data Rest исправляет ресурс Example и переопределяет значение для каждого запрошенного атрибута вместо того, чтобы пытаться копаться в свойствах объекта JSON, чтобы исправлять только запрошенные вложенные свойства.

Именно тогда мы подумали, что поддержка Spring Data Rest для типов носителей application/merge-patch+json и application/json-patch+json пригодится. Вот результаты для каждого типа носителя:

application/merge-patch+json:

PATCH /examples/1
{
    "jsonobject": {
        "foo": {"bar": "Welcome"}
    }
}

Вывод:

GET /examples/1
{
    "id": 1,
    "jsonobject": {
         "foo": {"bar": "Welcome"}
    }
}

application/json-patch+json:

PATCH /examples/1
[
    { "op": "replace", "path": "/jsonobject/foo/bar", "value": "Welcome" }
]

Вывод:

{
    "cause": {
        "cause": null,
        "message": "EL1008E:(pos 8): Property or field 'foo' cannot be found on object of type 'java.lang.String' - maybe not public?"
    },
    "message": "Could not read an object of type class com.example.Example from the request!; nested exception is org.springframework.expression.spel.SpelEvaluationException: EL1008E:(pos 8): Property or field 'foo' cannot be found on object of type 'java.lang.String' - maybe not public?"
}

Что сводится к той же идее: просматриваются только атрибуты сущности и либо полностью переопределяются, либо не обнаруживаются.

Вопрос в следующем: есть ли способ заставить Spring Data Rest понять, что он имеет дело с полем jsonb, и поэтому искать вложенные свойства JSON, а не только искать атрибуты сущности?

Nb: скорее всего, следует избегать аннотаций @Embeddable/@Embedded, поскольку они подразумевают знание имен вложенных свойств, что снижает интерес к полю jsonb.

Спасибо за чтение.


person Anthony Drogon    schedule 01.11.2016    source источник


Ответы (2)


ну, ваш EntityManager не знает, что внутри вашего поля jsonObject есть какая-то структура, которая для него является чистой строкой. Вы должны реализовать свои собственные обходные пути. Один из примеров того, как вы можете начать работать, приведен здесь https://github.com/bazar-nazar/pgjson Но такой подход потребует от вас каждый раз чтения объекта из базы данных и повторной сериализации/десериализации туда и обратно.

НО, ЕСЛИ вы используете postgresql, вы можете использовать всю его мощь (примечание: это сделает ваше приложение тесно связанным с postgresql, и, таким образом, базу данных будет сложнее заменить)

Я бы предложил реализовать пользовательские запросы jdbc, например, простой пример:

public static class JsonPatchRequest {
    String path;
    String operation;
    String value;
}


@Inject
private JdbcTemplate jdbcTemplate;

@PatchMapping(value = "/example/{id}") 
public void doPatch(@PathVariable("id") Long id, @RequestBody JsonPatchRequest patchRequest) {
    // this line should transform your request path from  "/jsonobject/foo/bar"  to "{foo,bar}" string
    String postgresqlpath = "{" + patchRequest.path.replaceFirst("/jsonobject/", "").replaceAll("/", ",") + "}";

    switch(patchRequest.operation) {
        case "replace" :
            jdbcTemplate.execute("UPDATE example SET jsonobject = jsonb_set(jsonobject, ?, jsonb ?) WHERE id = ?", new PreparedStatementCallback<Void>() {
                @Override
                public Void doInPreparedStatement(PreparedStatement ps) throws SQLException, DataAccessException {
                    ps.setString(1, postgresqlpath);

                    // this one transforms pure value, to string-escaped value (manual workaround)   so  'value' should become '"value"'
                    ps.setString(2, "\"".concat(patchRequest.value).concat("\""));

                    ps.setLong(3, id);

                    ps.execute();
                    return null;
                }
            });
            break;
        case "delete" :
            jdbcTemplate.execute("UPDATE example SET jsonobject = jsonobject #- ? WHERE id = ? ", new PreparedStatementCallback<Void>() {
                @Override
                public Void doInPreparedStatement(PreparedStatement ps) throws SQLException, DataAccessException {
                    ps.setString(1, postgresqlpath);
                    ps.setLong(2, id);
                    ps.execute();
                    return null;
                }
            });
            break;
    }
}

также обратите внимание: первый подход заставит вас сделать поле jsonobjet предопределенного типа, и, таким образом, его можно заменить чистой нормализованной сущностью, и поэтому с ним мало что можно сделать. Второй подход не заставляет вас иметь какую-либо структуру внутри вашего json.

Надеюсь, что это поможет вам.

person Ilya Dyoshin    schedule 09.11.2016
comment
Да, мы собираемся найти аналогичное решение. Единственная проблема заключается в том, что PostgreSQL, даже в версии 9.6, на самом деле не получает RFC для исправления слияния JSON. jsonb_set отвечает на проблему, но его необходимо выполнить для каждого вложенного свойства. Мы бы хотели, чтобы оператор конкатенации || не перезаписывал вслепую отсутствующие свойства, это было бы намного полезнее. Мы примем ваш ответ после ответа Дэвида Сиро. - person Anthony Drogon; 16.11.2016
comment
Я думаю, в этом случае вы могли бы использовать PL/v8 и создать собственную функцию для выполнения такого исправления. используя уже зарекомендовавшие себя решения (например, github.com/Starcounter-Jack/JSON-Patch) - person Ilya Dyoshin; 17.11.2016

Предполагается, что Hibernate 5 используется в качестве реализации JPA

Сделайте поле jsonobject определенным типом класса (с нужными вам полями) вместо String.

Затем вы можете добавить пользовательский тип пользователя Hibernate для jsonb типов.

@Entity
@Table(name = "examples")
public class Example {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @Basic
    @Type(type = "com.package.JsonObjectType")
    private JsonObject jsonobject;
}

Реализация пользовательского типа довольно многословна, но, по сути, она использует ObjectMapper Джексона для передачи объекта как String в оператор JDBC (и наоборот при извлечении из ResultSet).

public class JsonObjectType implements UserType {

    private ObjectMapper mapper = new ObjectMapper();

    @Override
    public int[] sqlTypes() {
        return new int[]{Types.JAVA_OBJECT};
    }

    @Override
    public Class<JsonObject> returnedClass() {
        return JsonObject.class;
    }

    @Override
    public Object nullSafeGet(ResultSet rs, String[] names, SharedSessionContractImplementor session, Object owner) throws HibernateException, SQLException {
        final String cellContent = rs.getString(names[0]);
        if (cellContent == null) {
            return null;
        }
        try {
            return mapper.readValue(cellContent.getBytes("UTF-8"), returnedClass());
        } catch (final Exception ex) {
            throw new HibernateException("Failed to convert String to Invoice: " + ex.getMessage(), ex);
        }
    }

    @Override
    public void nullSafeSet(PreparedStatement st, Object value, int index, SharedSessionContractImplementor session) throws HibernateException, SQLException {
        if (value == null) {
            st.setNull(index, Types.OTHER);
            return;
        }
        try {
            final StringWriter w = new StringWriter();
            mapper.writeValue(w, value);
            w.flush();
            st.setObject(index, w.toString(), Types.OTHER);
        } catch (final Exception ex) {
            throw new HibernateException("Failed to convert Invoice to String: " + ex.getMessage(), ex);
        }
    }

    @Override
    public Object deepCopy(final Object value) throws HibernateException {
        try {
            // use serialization to create a deep copy
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            ObjectOutputStream oos = new ObjectOutputStream(bos);
            oos.writeObject(value);
            oos.flush();
            oos.close();
            bos.close();

            ByteArrayInputStream bais = new ByteArrayInputStream(bos.toByteArray());
            return new ObjectInputStream(bais).readObject();
        } catch (ClassNotFoundException | IOException ex) {
            throw new HibernateException(ex);
        }
    }

    @Override
    public boolean isMutable() {
        return true;
    }

    @Override
    public Serializable disassemble(final Object value) throws HibernateException {
        return (Serializable) this.deepCopy(value);
    }

    @Override
    public Object assemble(final Serializable cached, final Object owner) throws HibernateException {
        return this.deepCopy(cached);
    }

    @Override
    public Object replace(final Object original, final Object target, final Object owner) throws HibernateException {
        return this.deepCopy(original);
    }

    @Override
    public boolean equals(final Object obj1, final Object obj2) throws HibernateException {
        if (obj1 == null) {
            return obj2 == null;
        }
        return obj1.equals(obj2);
    }

    @Override
    public int hashCode(final Object obj) throws HibernateException {
        return obj.hashCode();
    }
}

Наконец, вам нужно указать hibernate хранить объекты Java как тип jsonb Postgre. Это означает создание собственного диалектного класса (и его настройка).

public class MyPostgreSQL94Dialect extends PostgreSQL94Dialect {

    public MyPostgreSQL94Dialect() {
        this.registerColumnType(Types.JAVA_OBJECT, "jsonb");
    }
}

Со всем этим у вас все должно быть в порядке, и механизм исправления Spring Data Rest должен работать.

ПС

Ответ вдохновлен этим репозиторием github, который делает то же самое, но с Hibernate 4. Взгляните при этом.

person David Siro    schedule 09.11.2016
comment
Привет David. Что такое класс JsonObject? Вы написали с полями, которые вы хотите, поэтому ваш ответ подразумевает знание имен свойств сохраненного объекта JSON? - person Anthony Drogon; 16.11.2016
comment
Репозиторий GitHub отвечает на комментарий: их класс MyJson имеет два поля для хранения свойств JSON. Мы не хотим такого поведения: единственный интерес поля jsonb состоит в том, чтобы иметь возможность хранить любые данные, пока это правильный объект JSON. Таким образом, конечный пользователь, потребитель Spring Data Rest API, должен иметь возможность хранить объекты с любым свойством, если они сериализуемы в JsonObject, Gson или другую реализацию JSON. Наличие статических свойств в этом классе делает тип jsonb бесполезным, по крайней мере, для нас. Спасибо за ваше время в любом случае :) - person Anthony Drogon; 17.11.2016