Accessing a field name or alias while generating SQL in a JOOQ custom binding to populate MySQL JSON functions

recursinging :

Using JOOQ 3.5.2 with MySQL 5.7, I'm trying to accomplish the following...

MySQL has a set of JSON functions which allow for path targeted manipulation of properties inside larger documents.

I'm trying to make an abstraction which takes advantage of this using JOOQ. I began by creating JSON serializable document model which keeps track of changes and then implemented a JOOQ custom Binding for it.

In this binding, I have all the state information necessary to generate calls to these MySQL JSON functions with the exception of the qualified name or alias of the column being updated. A reference to this name is necessary for updating existing JSON documents in-place.

I have been unable to find a way to access this name from the *Context types available in the Binding interface.

I have been considering implementing a VisitListener to capture these field names and pass them through the Scope custom data map, but that option seems quite fragile.

What might the best way to gain access to the name of the field or alias being addressed within my Binding implementation?

--edit--

OK, to help clarify my goals here, take the following DDL:

create table widget (
  widget_id bigint(20) NOT NULL,
  jm_data json DEFAULT NULL,
  primary key (widget_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8

Now let's assume jm_data will hold the JSON representation of a java.util.Map<String,String>. For this JOOQ provides a very nice extension API by implementing and registering a custom data-type binding (in this case using Jackson):

public class MySQLJSONJacksonMapBinding implements Binding<Object, Map<String, String>> {
    private static final ObjectMapper mapper = new ObjectMapper();

    // The converter does all the work
    @Override
    public Converter<Object, Map<String, String>> converter() {
        return new Converter<Object, Map<String, String>>() {
            @Override
            public Map<String, String> from(final Object t) {
                try {
                    return t == null ? null
                            : mapper.readValue(t.toString(),
                                    new TypeReference<Map<String, String>>() {
                                    });
                } catch (final IOException e) {
                    throw new RuntimeException(e);
                }
            }

            @Override
            public Object to(final Map<String, String> u) {
                try {
                    return u == null ? null
                            : mapper.writer().writeValueAsString(u);
                } catch (final JsonProcessingException e) {
                    throw new RuntimeException(e);
                }
            }

            @Override
            public Class<Object> fromType() {
                return Object.class;
            }

            @Override
            public Class toType() {
                return Map.class;
            }
        };
    }

    // Rending a bind variable for the binding context's value and casting it to the json type
    @Override
    public void sql(final BindingSQLContext<Map<String, String>> ctx) throws SQLException {
        // Depending on how you generate your SQL, you may need to explicitly distinguish
        // between jOOQ generating bind variables or inlined literals. If so, use this check:
        // ctx.render().paramType() == INLINED
        ctx.render().visit(DSL.val(ctx.convert(converter()).value()));
    }

    // Registering VARCHAR types for JDBC CallableStatement OUT parameters
    @Override
    public void register(final BindingRegisterContext<Map<String, String>> ctx)
        throws SQLException {
        ctx.statement().registerOutParameter(ctx.index(), Types.VARCHAR);
    }

    // Converting the JsonElement to a String value and setting that on a JDBC PreparedStatement
    @Override
    public void set(final BindingSetStatementContext<Map<String, String>> ctx) throws SQLException {
        ctx.statement().setString(ctx.index(),
                Objects.toString(ctx.convert(converter()).value(), null));
    }

    // Getting a String value from a JDBC ResultSet and converting that to a Map
    @Override
    public void get(final BindingGetResultSetContext<Map<String, String>> ctx) throws SQLException {
        ctx.convert(converter()).value(ctx.resultSet().getString(ctx.index()));
    }

    // Getting a String value from a JDBC CallableStatement and converting that to a Map
    @Override
    public void get(final BindingGetStatementContext<Map<String, String>> ctx) throws SQLException {
        ctx.convert(converter()).value(ctx.statement().getString(ctx.index()));
    }

    // Setting a value on a JDBC SQLOutput (useful for Oracle OBJECT types)
    @Override
    public void set(final BindingSetSQLOutputContext<Map<String, String>> ctx) throws SQLException {
        throw new SQLFeatureNotSupportedException();
    }

    // Getting a value from a JDBC SQLInput (useful for Oracle OBJECT types)
    @Override
    public void get(final BindingGetSQLInputContext<Map<String, String>> ctx) throws SQLException {
        throw new SQLFeatureNotSupportedException();
    }
}

...this implementation is attached at build time by the code-gen like so:

<customTypes>
  <customType>
    <name>JsonMap</name>
    <type>java.util.Map&lt;String,String&gt;</type>
    <binding>com.orbiz.jooq.bindings.MySQLJSONJacksonMapBinding</binding>
  </customType>
</customTypes>
<forcedTypes>
  <forcedType>
    <name>JsonMap</name>
    <expression>jm_.*</expression>
    <types>json</types>
  </forcedType>
</forcedTypes>

...so with this in place, we have a nice, strongly typed java Map which we can manipulate in our application code. The binding implementation though, well it always writes the entire map contents to the JSON column, even if only a single map entry has been inserted, updated, or deleted. This implementation treats the MySQL JSON column like a normal VARCHAR column.

This approach poses two problems of varying significance depending on usage.

  1. Updating only a portion of a large map with many thousands of entries produces unnecessary SQL wire traffic as a side effect.
  2. If the contents of the map are user editable, and there are multiple users editing the contents at the same time, the changes of one may be overwritten by the other, even if they are non-conflicting.

MySQL 5.7 introduced the JSON data type, and a number of functions for manipulating documents in SQL. These functions make it possible to address the contents of the JSON documents, allowing for targeted updates of single properties. Continuing our example...:

insert into DEV.widget (widget_id, jm_data) 
values (1, '{"key0":"val0","key1":"val1","key2":"val2"}');

...the above Binding implementation would generate SQL like this if I were to change the java Map "key1" value to equal "updated_value1" and invoke an update on the record:

update DEV.widget 
set DEV.widget.jm_data = '{"key0":"val0","key1":"updated_value1","key2":"val2"}' 
where DEV.widget.widget_id = 1;

...notice the entire JSON string is being updated. MySQL can handle this more efficiently using the json_set function:

update DEV.widget 
set DEV.widget.jm_data = json_set( DEV.widget.jm_data, '$."key1"', 'updated_value1' ) 
where DEV.widget.widget_id = 1;

So, if I want to generate SQL like this, I need to first keep track of changes made to my Map from when it was initially read from the DB until it is to be updated. Then, using this change information, I can generate a call to the json_set function which will allow me to update only the modified properties in place.

Finally getting to my actual question. You'll notice in the SQL I wish to generate, the value of the column being updated contains a reference to the column itself json_set( DEV.widget.jm_data, .... This column (or alias) name does not seem to be available to the Binding API. I there a way to identify the name of the column of alias being updated from within my Binding implementation?

Lukas Eder :

Your Binding implementation is the wrong place to look for a solution to this problem. You don't really want to change the binding of your column to somehow magically know of this json_set function, which does an incremental update of the json data rather than a full replacement. When you use UpdatableRecord.store() (which you seem to be using), the expectation is for any Record.field(x) to reflect the content of the database row exactly, not a delta. Of course, you could implement something similar in the sql() method of your binding, but it would be very difficult to get right and the binding would not be applicable to all use-cases.

Hence, in order to do what you want to achieve, simply write an explicit UPDATE statement with jOOQ, enhancing the jOOQ API using plain SQL templating.

// Assuming this static import
import static org.jooq.impl.DSL.*;

public static Field<Map<String, String>> jsonSet(
    Field<Map<String, String>> field,
    String key,
    String value
) {
    return field("json_set({0}, {1}, {2})", field.getDataType(), field, inline(key), val(value));
}

Then, use your library method:

using(configuration)
    .update(WIDGET)
    .set(WIDGET.JM_DATA, jsonSet(WIDGET.JM_DATA, "$.\"key1\"", "updated_value1"))
    .where(WIDGET.WIDGET_ID.eq(1))
    .execute();

If this is getting too repetitive, I'm sure you can factor out common parts as well in some API of yours.

Guess you like

Origin http://43.154.161.224:23101/article/api/json?id=84164&siteId=1