Add json-columns module: JSON columns in Hibernate 7.4.14 (@JdbcTypeCode(SqlTypes.JSON)), JSONB querying, GIN/expression indexes, dirty checking, Jackson 3 vs 2 on PostgreSQL 18.6 and MySQL 8.0.46

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b6Kg9RWSTJzyz6KP9X
This commit is contained in:
Claude
2026-10-30 17:00:46 +00:00
parent 98f4ed7411
commit b83cc9988e
51 changed files with 3178 additions and 0 deletions
@@ -0,0 +1,93 @@
package com.ankurm.jsoncolumns;
import com.ankurm.jsoncolumns.model.*;
import static org.assertj.core.api.Assertions.assertThat;
import io.hypersistence.utils.hibernate.type.json.JsonType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.hibernate.SessionFactory;
import org.hibernate.annotations.Type;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.junit.jupiter.api.Test;
/** Run with -Phypersistence. What exists in Hibernate 7.4 of the old hibernate-types idiom, and does hypersistence-utils' JsonType still work? */
class A10_HypersistenceTest {
@Entity(name = "HDoc") @Table(name = "hdoc")
static class HDoc {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
@Type(JsonType.class) @Column(columnDefinition = "jsonb") Map<String, Object> data;
}
@Test
void hypersistence() throws IOException {
boolean j2 = present("com.fasterxml.jackson.databind.ObjectMapper");
Transcript t = new Transcript(j2 ? "10-hypersistence-with-jackson2.txt" : "10-hypersistence-jackson3-only.txt");
try { body(t, j2); } finally { t.save(); }
}
private void body(Transcript t, boolean j2) {
t.heading("what the 2018-2022 recipes rely on, checked against Hibernate " + org.hibernate.Version.getVersionString());
t.line("org.hibernate.annotations.TypeDef present? " + present("org.hibernate.annotations.TypeDef"));
t.line("org.hibernate.annotations.TypeDefs present? " + present("org.hibernate.annotations.TypeDefs"));
t.line("org.hibernate.annotations.Type attributes: " + Arrays.stream(Type.class.getDeclaredMethods()).map(Method::getName).sorted().toList());
assertThat(present("org.hibernate.annotations.TypeDef")).isFalse();
assertThat(Arrays.stream(Type.class.getDeclaredMethods()).map(Method::getName)).doesNotContain("type");
t.heading("jars on the test classpath that mention jackson or hypersistence");
Arrays.stream(System.getProperty("java.class.path").split(java.io.File.pathSeparator))
.map(p -> p.substring(p.lastIndexOf('/') + 1)).filter(n -> n.contains("jackson") || n.contains("hypersistence")).sorted().forEach(t::line);
String url = Db.newPgDatabase("jc_a10");
try (SessionFactory sf = Db.pg(url, Map.of(), HDoc.class, Product.class)) {
t.heading("FormatMapper Hibernate picked with hypersistence-utils on the classpath");
Class<?> fm = ((SessionFactoryImplementor) sf).getTypeConfiguration().getJsonFormatMapper().getClass();
t.line("getJsonFormatMapper() -> " + fm.getName());
assertThat(fm.getName()).endsWith(j2 ? ".JacksonJsonFormatMapper" : ".Jackson3JsonFormatMapper");
t.heading("@Type(JsonType.class) @Column(columnDefinition = \"jsonb\") on Hibernate 7.4.14");
HDoc d = new HDoc();
d.data = new LinkedHashMap<>(Map.of("k", "v", "n", 1));
try {
sf.inTransaction(s -> s.persist(d));
t.line("stored: " + Db.pgRows(url, "select pg_typeof(data) || ' ' || data::text from hdoc").get(0));
SqlLog.clear();
sf.inTransaction(s -> s.find(HDoc.class, d.id).data.put("k", "changed"));
long up = SqlLog.take().stream().filter(q -> q.startsWith("update")).count();
t.line("in-place Map.put -> " + up + " UPDATE; stored: " + Db.pgRows(url, "select data::text from hdoc").get(0));
assertThat(up).isEqualTo(1);
} catch (RuntimeException e) {
Throwable r = e;
while (r.getCause() != null) r = r.getCause();
t.line("FAILED " + r.getClass().getName() + ": " + (r.getMessage() == null ? "" : r.getMessage().lines().findFirst().orElse("")));
}
t.heading("the built-in mapping on the same classpath: Product (Map, List, record, POJO) round trip");
Product p = new Product("x", Map.of("a", 1), List.of("t"), new Dimensions(1, 2, 3), new Specs("m", 1.0, List.of()));
try {
sf.inTransaction(s -> s.persist(p));
Product back = sf.fromSession(s -> s.find(Product.class, p.getId()));
t.line("round trip OK: " + back.getDimensions() + " " + back.getSpecs().getMaterial());
} catch (RuntimeException e) {
Throwable r = e;
while (r.getCause() != null) r = r.getCause();
t.line("FAILED " + r.getClass().getName() + ": " + (r.getMessage() == null ? "" : r.getMessage().lines().findFirst().orElse("")));
}
}
}
private static boolean present(String cn) {
try { Class.forName(cn); return true; } catch (ClassNotFoundException e) { return false; }
}
}
@@ -0,0 +1,4 @@
package com.ankurm.jsoncolumns.model;
/** A Java record stored as a JSON object: {"widthMm":..,"heightMm":..,"depthMm":..}. */
public record Dimensions(int widthMm, int heightMm, int depthMm) { }
@@ -0,0 +1,30 @@
package com.ankurm.jsoncolumns.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.Map;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/** The table the indexing chapter fills with 50,000 rows; Hibernate only creates it, the rows come from generate_series. */
@Entity
@Table(name = "event")
public class Event {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@JdbcTypeCode(SqlTypes.JSON)
private Map<String, Object> payload;
public Event() { }
public Event(Map<String, Object> payload) { this.payload = payload; }
public Long getId() { return id; }
public Map<String, Object> getPayload() { return payload; }
public void setPayload(Map<String, Object> payload) { this.payload = payload; }
}
@@ -0,0 +1,8 @@
package com.ankurm.jsoncolumns.model;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** Minimal Spring Boot 4 application used by A11_SpringBootTest. */
@SpringBootApplication
public class JsonColumnsApplication {
}
@@ -0,0 +1,56 @@
package com.ankurm.jsoncolumns.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.List;
import java.util.Map;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/** Four Java shapes, one annotation: Map, List, record and POJO, each in its own JSON column. */
@Entity
@Table(name = "product")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@JdbcTypeCode(SqlTypes.JSON)
private Map<String, Object> attributes;
@JdbcTypeCode(SqlTypes.JSON)
private List<String> tags;
@JdbcTypeCode(SqlTypes.JSON)
private Dimensions dimensions;
@JdbcTypeCode(SqlTypes.JSON)
private Specs specs;
public Product() { }
public Product(String name, Map<String, Object> attributes, List<String> tags, Dimensions dimensions, Specs specs) {
this.name = name;
this.attributes = attributes;
this.tags = tags;
this.dimensions = dimensions;
this.specs = specs;
}
public Long getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Map<String, Object> getAttributes() { return attributes; }
public void setAttributes(Map<String, Object> attributes) { this.attributes = attributes; }
public List<String> getTags() { return tags; }
public void setTags(List<String> tags) { this.tags = tags; }
public Dimensions getDimensions() { return dimensions; }
public void setDimensions(Dimensions dimensions) { this.dimensions = dimensions; }
public Specs getSpecs() { return specs; }
public void setSpecs(Specs specs) { this.specs = specs; }
}
@@ -0,0 +1,25 @@
package com.ankurm.jsoncolumns.model;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ProductRepository extends JpaRepository<Product, Long> {
/** Native PostgreSQL containment; the parameter is JSON text and the cast is written out so Hibernate's parser leaves it alone. */
@Query(value = "select * from product where attributes @> cast(:json as jsonb) order by id", nativeQuery = true)
List<Product> findByAttributesContaining(@Param("json") String json);
/** HQL json_value(): needs hibernate.query.hql.json_functions_enabled=true. */
@Query("select p from Product p where json_value(p.attributes, '$.color') = :color order by p.id")
List<Product> findByColor(@Param("color") String color);
@Modifying
@Query("update Product p set p.attributes = json_set(p.attributes, '$.color', :color) where p.id = :id")
int repaint(@Param("id") Long id, @Param("color") String color);
/** Derived query on a whole record value: equality on the jsonb column. */
List<Product> findByDimensions(Dimensions dimensions);
}
@@ -0,0 +1,19 @@
package com.ankurm.jsoncolumns.model;
import org.hibernate.type.format.jackson.Jackson3JsonFormatMapper;
import org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import tools.jackson.databind.json.JsonMapper;
/** Hands Spring Boot's own JsonMapper bean to Hibernate, so both serialise JSON the same way. Active with profile shared-mapper. */
@Configuration
@Profile("shared-mapper")
public class SharedMapperConfig {
@Bean
HibernatePropertiesCustomizer hibernateUsesBootsJsonMapper(JsonMapper bootMapper) {
return props -> props.put("hibernate.type.json_format_mapper", new Jackson3JsonFormatMapper(bootMapper));
}
}
@@ -0,0 +1,39 @@
package com.ankurm.jsoncolumns.model;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* A mutable POJO stored as a JSON object. Mutable on purpose: the dirty-checking test changes it in place.
* It has equals()/hashCode(): without them Hibernate cannot tell "unchanged" from "changed" (see A03_DirtyCheckingTest).
*/
public class Specs {
private String material;
private double weightKg;
private List<String> certifications = new ArrayList<>();
public Specs() { }
public Specs(String material, double weightKg, List<String> certifications) {
this.material = material;
this.weightKg = weightKg;
this.certifications = new ArrayList<>(certifications);
}
public String getMaterial() { return material; }
public void setMaterial(String material) { this.material = material; }
public double getWeightKg() { return weightKg; }
public void setWeightKg(double weightKg) { this.weightKg = weightKg; }
public List<String> getCertifications() { return certifications; }
public void setCertifications(List<String> certifications) { this.certifications = certifications; }
@Override
public boolean equals(Object o) {
return o instanceof Specs s && Objects.equals(material, s.material) && weightKg == s.weightKg
&& Objects.equals(certifications, s.certifications);
}
@Override
public int hashCode() { return Objects.hash(material, weightKg, certifications); }
}
@@ -0,0 +1,134 @@
package com.ankurm.jsoncolumns;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.List;
import java.util.Map;
import org.hibernate.SessionFactory;
import org.hibernate.annotations.JdbcType;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.dialect.type.PostgreSQLJsonPGObjectJsonType;
import org.hibernate.dialect.type.PostgreSQLJsonPGObjectJsonbType;
import org.hibernate.type.SqlTypes;
import org.junit.jupiter.api.Test;
/** What DDL does Hibernate 7.4 emit on PostgreSQL for SqlTypes.JSON, and how do you get json or jsonb on purpose? */
class A01_DdlTest {
@Entity(name = "DefaultJson") @Table(name = "t_default")
static class DefaultJson {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
@JdbcTypeCode(SqlTypes.JSON) Map<String, Object> doc;
}
@Entity(name = "ColumnDefJson") @Table(name = "t_coldef_json")
static class ColumnDefJson {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
@JdbcTypeCode(SqlTypes.JSON) @Column(columnDefinition = "json") Map<String, Object> doc;
}
@Entity(name = "ColumnDefJsonb") @Table(name = "t_coldef_jsonb")
static class ColumnDefJsonb {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
@JdbcTypeCode(SqlTypes.JSON) @Column(columnDefinition = "jsonb") Map<String, Object> doc;
}
@Entity(name = "JdbcTypeJson") @Table(name = "t_jdbctype_json")
static class JdbcTypeJson {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
@JdbcType(PostgreSQLJsonPGObjectJsonType.class) Map<String, Object> doc;
}
@Entity(name = "JdbcTypeJsonb") @Table(name = "t_jdbctype_jsonb")
static class JdbcTypeJsonb {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
@JdbcType(PostgreSQLJsonPGObjectJsonbType.class) Map<String, Object> doc;
}
@Entity(name = "ListJson") @Table(name = "t_list")
static class ListJson {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
@JdbcTypeCode(SqlTypes.JSON) List<String> items;
}
@Test
void ddlAndRoundTripPerVariant() {
String url = Db.newPgDatabase("jc_a01");
Transcript t = new Transcript("01-ddl-postgresql.txt");
t.line("# " + Db.pgRows(url, "select version()").get(0));
t.line("# Hibernate " + org.hibernate.Version.getVersionString());
Class<?>[] all = {DefaultJson.class, ColumnDefJson.class, ColumnDefJsonb.class, JdbcTypeJson.class, JdbcTypeJsonb.class, ListJson.class};
t.heading("CREATE TABLE as Hibernate emits it (JPA script generation, nothing executed)");
for (String l : Db.createScript(Db.props(url, Db.PG_USER, Map.of()), all)) {
if (l.startsWith("create table t_")) t.line(l);
}
try (SessionFactory sf = Db.pg(url, Map.of(), all)) {
sf.inSession(s -> s.doWork(c -> { }));
t.heading("information_schema after hbm2ddl=create (column, data_type)");
List<String> cols = Db.pgRows(url, "select table_name || '.' || column_name || ' | ' || data_type from information_schema.columns "
+ "where table_schema='public' and column_name in ('doc','items') order by table_name");
cols.forEach(t::line);
Map<String, Object> doc = Map.of("a", 1, "b", List.of("x", "y"));
sf.inTransaction(s -> {
for (Object e : new Object[] {new DefaultJson(), new ColumnDefJson(), new ColumnDefJsonb(), new JdbcTypeJson(), new JdbcTypeJsonb()}) {
switch (e) {
case DefaultJson d -> d.doc = doc;
case ColumnDefJson d -> d.doc = doc;
case ColumnDefJsonb d -> d.doc = doc;
case JdbcTypeJson d -> d.doc = doc;
case JdbcTypeJsonb d -> d.doc = doc;
default -> throw new IllegalStateException();
}
s.persist(e);
}
ListJson l = new ListJson(); l.items = List.of("p", "q"); s.persist(l);
});
t.heading("what is stored: pg_typeof(doc) and the text PostgreSQL returns");
for (String tb : List.of("t_default", "t_coldef_json", "t_coldef_jsonb", "t_jdbctype_json", "t_jdbctype_jsonb")) {
t.line(tb + " | " + Db.pgRows(url, "select pg_typeof(doc) || ' | ' || doc::text from " + tb).get(0));
}
t.line("t_list | " + Db.pgRows(url, "select pg_typeof(items) || ' | ' || items::text from t_list").get(0));
assertThat(cols).contains("t_default.doc | jsonb", "t_coldef_json.doc | json", "t_coldef_jsonb.doc | jsonb",
"t_jdbctype_json.doc | jsonb", "t_jdbctype_jsonb.doc | jsonb", "t_list.items | jsonb");
assertThat(Db.pgRows(url, "select pg_typeof(doc)::text from t_default")).containsExactly("jsonb");
assertThat(Db.pgRows(url, "select pg_typeof(doc)::text from t_jdbctype_json")).containsExactly("jsonb");
t.heading("reading each variant back through Hibernate");
sf.inSession(s -> {
t.line("t_default -> " + s.createQuery("select d.doc from DefaultJson d", Map.class).getSingleResult());
t.line("t_coldef_json -> " + s.createQuery("select d.doc from ColumnDefJson d", Map.class).getSingleResult());
t.line("t_jdbctype_json-> " + s.createQuery("select d.doc from JdbcTypeJson d", Map.class).getSingleResult());
assertThat(s.createQuery("select d.doc from JdbcTypeJson d", Map.class).getSingleResult()).isEqualTo(doc);
assertThat(s.createQuery("select d.doc from ColumnDefJson d", Map.class).getSingleResult()).isEqualTo(doc);
});
}
t.heading("json vs jsonb is observable: key order, duplicate keys and whitespace");
Db.exec(url, Db.PG_USER, "create table j_cmp (a json, b jsonb)");
Db.exec(url, Db.PG_USER, "insert into j_cmp values ('{\"z\": 1, \"a\": 2, \"a\": 3}', '{\"z\": 1, \"a\": 2, \"a\": 3}')");
String cmp = Db.pgRows(url, "select a::text || ' | ' || b::text from j_cmp").get(0);
t.line("json | jsonb: " + cmp);
assertThat(cmp).isEqualTo("{\"z\": 1, \"a\": 2, \"a\": 3} | {\"a\": 3, \"z\": 1}");
try {
Db.exec(url, Db.PG_USER, "select a = a from j_cmp");
t.line("json = json: worked");
} catch (IllegalStateException e) {
t.line("json = json: " + e.getCause().getMessage().lines().findFirst().orElse(""));
}
t.line("jsonb = jsonb: " + Db.pgRows(url, "select b = b from j_cmp").get(0));
save(t);
}
private static void save(Transcript t) {
try { t.save(); } catch (java.io.IOException e) { throw new IllegalStateException(e); }
}
}
@@ -0,0 +1,87 @@
package com.ankurm.jsoncolumns;
import com.ankurm.jsoncolumns.model.*;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.hibernate.SessionFactory;
import org.junit.jupiter.api.Test;
/** Map, List, record and POJO in JSON columns: what is written, what comes back, and which Java types survive. */
class A02_MappingTest {
@Test
void roundTripFourShapes() throws IOException {
String url = Db.newPgDatabase("jc_a02");
Transcript t = new Transcript("02-mapping-roundtrip.txt");
try (SessionFactory sf = Db.pg(url, Map.of(), Product.class)) {
Map<String, Object> attrs = new LinkedHashMap<>();
attrs.put("color", "red");
attrs.put("stock", 12);
attrs.put("bigNumber", 3_000_000_000L);
attrs.put("price", new BigDecimal("19.90"));
attrs.put("ratio", 0.5d);
attrs.put("active", true);
attrs.put("nothing", null);
attrs.put("nested", Map.of("k", List.of(1, 2, 3)));
Product p = new Product("Desk lamp", attrs, List.of("lighting", "office"),
new Dimensions(180, 420, 180), new Specs("steel", 1.8, List.of("CE", "RoHS")));
SqlLog.clear();
sf.inTransaction(s -> s.persist(p));
t.heading("persist(): the INSERT Hibernate sends (one bind per JSON column)");
SqlLog.take().forEach(q -> t.line("sql> " + q));
t.heading("what PostgreSQL stored (jsonb text)");
for (String col : List.of("attributes", "tags", "dimensions", "specs")) {
t.line(col + " = " + Db.pgRows(url, "select " + col + "::text from product").get(0));
}
assertThat(Db.pgRows(url, "select dimensions::text from product")).containsExactly("{\"depthMm\": 180, \"widthMm\": 180, \"heightMm\": 420}");
assertThat(Db.pgRows(url, "select tags::text from product")).containsExactly("[\"lighting\", \"office\"]");
t.heading("loaded back in a new session: Java types of the Map values");
sf.inSession(s -> {
Product r = s.find(Product.class, p.getId());
r.getAttributes().forEach((k, v) -> t.line(String.format("%-10s -> %-12s %s", k, v == null ? "null" : v.getClass().getSimpleName(), v)));
assertThat(r.getAttributes().get("stock")).isInstanceOf(Integer.class);
assertThat(r.getAttributes().get("bigNumber")).isInstanceOf(Long.class);
assertThat(r.getAttributes().get("price")).isInstanceOf(Double.class);
assertThat(r.getAttributes().get("price")).isEqualTo(19.9d);
assertThat(r.getAttributes()).containsKey("nothing");
assertThat(r.getAttributes().get("nothing")).isNull();
assertThat(r.getAttributes().get("nested")).isInstanceOf(Map.class);
assertThat(r.getTags()).containsExactly("lighting", "office");
assertThat(r.getDimensions()).isEqualTo(new Dimensions(180, 420, 180));
assertThat(r.getSpecs().getMaterial()).isEqualTo("steel");
assertThat(r.getSpecs().getCertifications()).containsExactly("CE", "RoHS");
t.line("dimensions -> " + r.getDimensions());
t.line("specs -> " + r.getSpecs().getMaterial() + ", " + r.getSpecs().getWeightKg() + ", " + r.getSpecs().getCertifications());
});
}
t.heading("Java null vs JSON null vs empty");
try (SessionFactory sf = Db.pg(url, Map.of(), Product.class)) {
sf.inTransaction(s -> {
s.persist(new Product("null-attrs", null, null, null, null));
s.persist(new Product("empty-attrs", new LinkedHashMap<>(), new ArrayList<>(), null, null));
});
for (String r : Db.pgRows(url, "select name || ' | attributes=' || coalesce(attributes::text,'SQL NULL') || ' | tags=' || coalesce(tags::text,'SQL NULL') || ' | dimensions=' || coalesce(dimensions::text,'SQL NULL') from product where name like '%-attrs' order by name desc")) t.line(r);
assertThat(Db.pgRows(url, "select count(*) from product where name='null-attrs' and attributes is null and tags is null")).containsExactly("1");
assertThat(Db.pgRows(url, "select attributes::text from product where name='empty-attrs'")).containsExactly("{}");
sf.inSession(s -> {
Product n = s.createQuery("from Product where name='null-attrs'", Product.class).getSingleResult();
Product e = s.createQuery("from Product where name='empty-attrs'", Product.class).getSingleResult();
t.line("loaded null-attrs : attributes=" + n.getAttributes() + " tags=" + n.getTags());
t.line("loaded empty-attrs : attributes=" + e.getAttributes() + " tags=" + e.getTags());
assertThat(n.getAttributes()).isNull();
assertThat(e.getAttributes()).isEmpty();
});
}
t.save();
}
}
@@ -0,0 +1,157 @@
package com.ankurm.jsoncolumns;
import com.ankurm.jsoncolumns.model.*;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import org.hibernate.SessionFactory;
import org.hibernate.annotations.DynamicUpdate;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import org.junit.jupiter.api.Test;
/** Does Hibernate notice a JSON attribute that was changed in place? And when does it UPDATE although nothing changed? */
class A03_DirtyCheckingTest {
/** The same POJO as Specs but WITHOUT equals()/hashCode(). */
public static class LooseSpecs {
private String material = "steel";
public String getMaterial() { return material; }
public void setMaterial(String material) { this.material = material; }
}
@Entity(name = "Loose") @Table(name = "loose")
static class Loose {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
@JdbcTypeCode(SqlTypes.JSON) LooseSpecs specs = new LooseSpecs();
}
@Entity(name = "Dyn") @Table(name = "dyn") @DynamicUpdate
static class Dyn {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
String name = "dyn";
@JdbcTypeCode(SqlTypes.JSON) Map<String, Object> a = new LinkedHashMap<>(Map.of("k", "v"));
@JdbcTypeCode(SqlTypes.JSON) Map<String, Object> b = new LinkedHashMap<>(Map.of("k", "v"));
}
private static Product clean() {
Map<String, Object> attrs = new LinkedHashMap<>();
attrs.put("color", "red");
attrs.put("stock", 12);
attrs.put("price", 19.9d);
attrs.put("nested", new LinkedHashMap<>(Map.of("k", new ArrayList<>(List.of(1, 2, 3)))));
return new Product("lamp", attrs, new ArrayList<>(List.of("a", "b")), new Dimensions(1, 2, 3),
new Specs("steel", 1.8, List.of("CE")));
}
private static long updates(List<String> sql) { return sql.stream().filter(s -> s.startsWith("update")).count(); }
/** Runs {@code mutate} on a freshly loaded Product inside one transaction; returns the UPDATEs issued at commit. */
private static List<String> mutateAndCommit(SessionFactory sf, Long id, Consumer<Product> mutate) {
SqlLog.clear();
sf.inTransaction(s -> mutate.accept(s.find(Product.class, id)));
return SqlLog.take().stream().filter(q -> q.startsWith("update")).toList();
}
@Test
void whatIsDetected() throws IOException {
String url = Db.newPgDatabase("jc_a03");
Transcript t = new Transcript("03-dirty-checking.txt");
try (SessionFactory sf = Db.pg(url, Map.of(), Product.class, Dyn.class, Loose.class)) {
Product p = clean();
SqlLog.clear();
sf.inTransaction(s -> s.persist(p));
t.heading("baseline: persist a Product whose Map holds only JSON-native Java types (String, Integer, Double, Map, List)");
SqlLog.take().forEach(q -> t.line("sql> " + q));
Long id = p.getId();
List<String> none = mutateAndCommit(sf, id, x -> { });
t.heading("load, change nothing, commit");
t.line("UPDATE statements: " + none.size());
assertThat(none).isEmpty();
t.heading("in-place mutations: load, mutate the object graph, commit, count UPDATEs");
record Case(String label, Consumer<Product> mutation) { }
List<Case> cases = List.of(
new Case("attributes.put(\"color\", \"blue\")", x -> x.getAttributes().put("color", "blue")),
new Case("attributes.remove(\"stock\")", x -> x.getAttributes().remove("stock")),
new Case("((Map) attributes.get(\"nested\")).put(\"k2\", 1) [nested map]", x -> ((Map<String, Object>) x.getAttributes().get("nested")).put("k2", 1)),
new Case("((List) nested.get(\"k\")).add(4) [list inside map]", x -> ((List<Object>) ((Map<String, Object>) x.getAttributes().get("nested")).get("k")).add(4)),
new Case("tags.add(\"c\")", x -> x.getTags().add("c")),
new Case("specs.setMaterial(\"brass\") [POJO setter]", x -> x.getSpecs().setMaterial("brass")),
new Case("specs.getCertifications().add(\"UL\") [list inside POJO]", x -> x.getSpecs().getCertifications().add("UL")),
new Case("setDimensions(new Dimensions(9,9,9)) [record replaced]", x -> x.setDimensions(new Dimensions(9, 9, 9))),
new Case("setDimensions(equal record) [no real change]", x -> x.setDimensions(new Dimensions(9, 9, 9)))
);
for (Case c : cases) {
List<String> up = mutateAndCommit(sf, id, c.mutation());
t.line(String.format("%-68s -> %d UPDATE", c.label(), up.size()));
if (!up.isEmpty()) t.line(" " + up.get(0));
if (c.label().contains("equal record")) assertThat(up).isEmpty(); else assertThat(up).hasSize(1);
}
t.heading("the changes really reached the database");
t.line("attributes = " + Db.pgRows(url, "select attributes::text from product where id=" + id).get(0));
t.line("tags = " + Db.pgRows(url, "select tags::text from product where id=" + id).get(0));
t.line("specs = " + Db.pgRows(url, "select specs::text from product where id=" + id).get(0));
assertThat(Db.pgRows(url, "select attributes->>'color' from product where id=" + id)).containsExactly("blue");
assertThat(Db.pgRows(url, "select specs->'certifications'::text from product where id=" + id).get(0)).contains("UL");
}
t.heading("spurious UPDATEs: nothing changed, Hibernate writes anyway");
try (SessionFactory sf = Db.pg(url, Map.of(), Product.class, Dyn.class, Loose.class)) {
Product big = clean();
big.getAttributes().put("price", new BigDecimal("19.90"));
SqlLog.clear();
sf.inTransaction(s -> s.persist(big));
List<String> sql = SqlLog.take();
t.line("persist() with attributes.price = new BigDecimal(\"19.90\") -> " + updates(sql) + " UPDATE right after the INSERT");
sql.forEach(q -> t.line(" sql> " + q));
assertThat(updates(sql)).isEqualTo(1);
t.line("stored price after that persist: " + Db.pgRows(url, "select attributes->'price' from product where id=" + big.getId()).get(0));
SqlLog.clear();
sf.inTransaction(s -> s.find(Product.class, big.getId()));
t.line("then a plain load + commit of that row -> " + updates(SqlLog.take()) + " UPDATE");
Loose loose = new Loose();
sf.inTransaction(s -> s.persist(loose));
SqlLog.clear();
sf.inTransaction(s -> s.find(Loose.class, loose.id));
List<String> looseSql = SqlLog.take();
t.line("load + commit of a row whose POJO has NO equals(), nothing touched -> " + updates(looseSql) + " UPDATE");
looseSql.forEach(q -> t.line(" sql> " + q));
assertThat(updates(looseSql)).isEqualTo(1);
SqlLog.clear();
Product withSpecs = clean();
sf.inTransaction(s -> s.persist(withSpecs));
SqlLog.clear();
sf.inTransaction(s -> s.find(Product.class, withSpecs.getId()));
t.line("same for Specs, which HAS equals() (Product row, nothing touched) -> " + updates(SqlLog.take()) + " UPDATE");
}
t.heading("@DynamicUpdate: only the changed JSON column is written");
try (SessionFactory sf = Db.pg(url, Map.of(), Product.class, Dyn.class, Loose.class)) {
Dyn d = new Dyn();
sf.inTransaction(s -> s.persist(d));
SqlLog.clear();
sf.inTransaction(s -> s.find(Dyn.class, d.id).a.put("k", "changed"));
List<String> up = SqlLog.take().stream().filter(q -> q.startsWith("update")).toList();
up.forEach(q -> t.line("sql> " + q));
assertThat(up).hasSize(1);
assertThat(up.get(0)).contains("set a=?").doesNotContain("b=?");
}
t.save();
}
}
@@ -0,0 +1,99 @@
package com.ankurm.jsoncolumns;
import com.ankurm.jsoncolumns.model.*;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.hibernate.SessionFactory;
import org.junit.jupiter.api.Test;
/** Every JSON function Hibernate 7.4 documents for HQL, executed against PostgreSQL 18. Each line is OK or FAILED with the real message. */
class A04_HqlJsonFunctionsTest {
private static final Map<String, String> EXPECTED = new LinkedHashMap<>();
/** The HQL statements shared with the MySQL run (A08). */
static Map<String, String> cases() {
Map<String, String> cases = new LinkedHashMap<>();
// reading
cases.put("json_value, string", "select p.name, json_value(p.attributes, '$.color') from Product p order by p.id");
cases.put("json_value, returning integer", "select p.name, json_value(p.attributes, '$.stock' returning integer) from Product p order by p.id");
cases.put("json_value in where", "select p.name from Product p where json_value(p.attributes, '$.color') = 'black' order by p.id");
cases.put("json_value nested path + array index", "select p.name, json_value(p.attributes, '$.nested.aisles[1]' returning integer) from Product p order by p.id");
cases.put("json_value on a record column", "select p.name, json_value(p.dimensions, '$.widthMm' returning integer) from Product p order by p.id");
cases.put("json_value on a list column", "select p.name, json_value(p.tags, '$[0]') from Product p order by p.id");
cases.put("json_value, missing key", "select p.name, json_value(p.attributes, '$.nope') from Product p where p.id = 1");
cases.put("json_query (sub-document)", "select p.name, json_query(p.attributes, '$.nested') from Product p where p.id = 1");
cases.put("json_query with wrapper", "select p.name, json_query(p.attributes, '$.nested.aisles[*]' with wrapper) from Product p where p.id = 1");
cases.put("json_exists", "select p.name from Product p where json_exists(p.attributes, '$.nested.warehouse') order by p.id");
cases.put("json_exists with filter", "select p.name from Product p where json_exists(p.attributes, '$.stock ? (@ > 5)') order by p.id");
// building
cases.put("json_object", "select json_object('name', p.name, 'id', p.id) from Product p where p.id = 1");
cases.put("json_object, key/value keywords", "select json_object(key 'name' value p.name) from Product p where p.id = 1");
cases.put("json_array", "select json_array(p.name, p.id, null) from Product p where p.id = 1");
cases.put("json_arrayagg", "select json_arrayagg(p.name) from Product p");
cases.put("json_objectagg", "select json_objectagg(key p.name value p.id) from Product p");
// table
cases.put("json_table over a list column", "select p.name, t.tag from Product p join lateral json_table(p.tags, '$[*]' columns (tag String path '$')) t where p.id = 1");
cases.put("json_table, tags counted", "select t.tag, count(*) from Product p join lateral json_table(p.tags, '$[*]' columns (tag String path '$')) t group by t.tag order by t.tag");
// modifying (UPDATE ... SET)
cases.put("json_set", "update Product p set p.attributes = json_set(p.attributes, '$.color', 'green') where p.id = 1");
cases.put("json_insert", "update Product p set p.attributes = json_insert(p.attributes, '$.brand', 'Acme') where p.id = 1");
cases.put("json_replace", "update Product p set p.attributes = json_replace(p.attributes, '$.stock', 99) where p.id = 1");
cases.put("json_remove", "update Product p set p.attributes = json_remove(p.attributes, '$.price') where p.id = 1");
cases.put("json_mergepatch", "update Product p set p.attributes = json_mergepatch(p.attributes, '{\"stock\": 1, \"brand\": null}') where p.id = 2");
cases.put("json_array_append, root path $", "update Product p set p.tags = json_array_append(p.tags, '$', 'sale') where p.id = 1");
cases.put("json_array_append, nested path", "update Product p set p.attributes = json_array_append(p.attributes, '$.nested.aisles', 11) where p.id = 1");
cases.put("json_array_insert", "update Product p set p.tags = json_array_insert(p.tags, '$[0]', 'first') where p.id = 2");
// path navigation without a function
cases.put("attribute navigation p.attributes.color", "select p.name from Product p where p.attributes.color = 'black'");
cases.put("function('jsonb_extract_path_text')", "select function('jsonb_extract_path_text', p.attributes, 'color') from Product p where p.id = 3");
return cases;
}
@Test
void hqlFunctions() throws IOException {
String url = Db.newPgDatabase("jc_a04");
Transcript t = new Transcript("04-hql-json-functions-postgresql.txt");
t.line("# " + Db.pgRows(url, "select version()").get(0).split(",")[0]);
try (SessionFactory off = Db.pg(url, Map.of(), Product.class)) {
Seed.products(off);
t.heading("PART 1: default settings (hibernate.query.hql.json_functions_enabled not set)");
Map<String, String> gate = new LinkedHashMap<>();
gate.put("default: json_value", "select p.name, json_value(p.attributes, '$.color') from Product p where p.id = 1");
gate.put("default: json_exists", "select p.name from Product p where json_exists(p.attributes, '$.color')");
gate.put("default: json_set (UPDATE)", "update Product p set p.attributes = json_set(p.attributes, '$.color', 'green') where p.id = 1");
HqlRunner.run(off, gate, t, EXPECTED);
assertThat(EXPECTED.get("default: json_value")).startsWith("FAILED").contains("json_functions_enabled");
assertThat(EXPECTED.get("default: json_exists")).startsWith("FAILED");
assertThat(EXPECTED.get("default: json_set (UPDATE)")).startsWith("OK");
}
t.heading("PART 2: hibernate.query.hql.json_functions_enabled=true");
try (SessionFactory sf = Db.pg(url, Map.of("hibernate.query.hql.json_functions_enabled", true), Product.class)) {
Seed.products(sf);
Map<String, String> cases = cases();
HqlRunner.run(sf, cases, t, EXPECTED);
assertThat(EXPECTED.get("json_value in where")).isEqualTo("OK Floor lamp; Office chair");
assertThat(EXPECTED.get("json_value, returning integer")).startsWith("OK (Desk lamp, 12);");
assertThat(EXPECTED.get("json_value nested path + array index")).startsWith("OK (Desk lamp, 7);");
assertThat(EXPECTED.get("json_value, missing key")).isEqualTo("OK (Desk lamp, null)");
assertThat(EXPECTED.get("json_query (sub-document)")).isEqualTo("OK (Desk lamp, {\"aisles\": [3, 7], \"warehouse\": \"PUNE\"})");
assertThat(EXPECTED.get("json_exists with filter")).isEqualTo("OK Desk lamp; Bookshelf; Monitor arm");
assertThat(EXPECTED.get("json_arrayagg")).isEqualTo("OK [\"Desk lamp\", \"Floor lamp\", \"Office chair\", \"Bookshelf\", \"Monitor arm\"]");
assertThat(EXPECTED.get("json_table, tags counted")).isEqualTo("OK (accessories, 1); (furniture, 2); (home, 2); (lighting, 2); (office, 3)");
for (String ok : List.of("json_set", "json_insert", "json_replace", "json_remove", "json_mergepatch",
"json_array_append, nested path", "json_array_insert", "json_object", "json_array", "json_objectagg",
"function('jsonb_extract_path_text')")) {
assertThat(EXPECTED.get(ok)).as(ok).startsWith("OK");
}
assertThat(EXPECTED.get("json_array_append, root path $")).startsWith("FAILED SQLGrammarException");
assertThat(EXPECTED.get("attribute navigation p.attributes.color")).startsWith("FAILED IllegalArgumentException");
t.heading("rows after the UPDATE experiments (raw jsonb)");
Db.pgRows(url, "select id || ' ' || attributes::text || ' ' || tags::text from product where id in (1,2) order by id").forEach(t::line);
}
t.save();
}
}
@@ -0,0 +1,80 @@
package com.ankurm.jsoncolumns;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.Embeddable;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import org.hibernate.SessionFactory;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import org.junit.jupiter.api.Test;
/** An @Embeddable stored as one JSON column: a typed model whose attributes HQL can navigate without json_value(). */
class A05_EmbeddableJsonTest {
@Embeddable
public record Address(String city, String zip, int floor) { }
@Entity(name = "Customer") @Table(name = "customer")
static class Customer {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
String name;
@JdbcTypeCode(SqlTypes.JSON) Address address;
Customer() { }
Customer(String name, Address address) { this.name = name; this.address = address; }
}
@Test
void embeddableAsJson() throws IOException {
String url = Db.newPgDatabase("jc_a05");
Transcript t = new Transcript("05-embeddable-json-postgresql.txt");
t.line("# " + Db.pgRows(url, "select version()").get(0).split(",")[0]);
try (SessionFactory sf = Db.pg(url, Map.of(), Customer.class)) {
t.heading("DDL");
Db.pgRows(url, "select column_name || ' ' || data_type from information_schema.columns where table_name='customer' order by ordinal_position").forEach(t::line);
sf.inTransaction(s -> {
s.persist(new Customer("Asha", new Address("Pune", "411001", 3)));
s.persist(new Customer("Ravi", new Address("Mumbai", "400001", 12)));
s.persist(new Customer("Meera", new Address("Pune", "411045", 1)));
});
t.heading("stored");
Db.pgRows(url, "select name || ' ' || address::text from customer order by id").forEach(t::line);
SqlLog.clear();
List<String> pune = sf.fromSession(s -> s.createQuery("select c.name from Customer c where c.address.city = 'Pune' order by c.id", String.class).getResultList());
t.heading("HQL: where c.address.city = 'Pune'");
SqlLog.take().forEach(q -> t.line("sql> " + q));
t.line("result: " + pune);
assertThat(pune).containsExactly("Asha", "Meera");
SqlLog.clear();
List<Integer> floors = sf.fromSession(s -> s.createQuery("select c.address.floor from Customer c where c.address.floor > 2 order by c.address.floor", Integer.class).getResultList());
t.heading("HQL: select and order by an int attribute inside the JSON");
SqlLog.take().forEach(q -> t.line("sql> " + q));
t.line("result: " + floors);
assertThat(floors).containsExactly(3, 12);
SqlLog.clear();
int n = sf.fromTransaction(s -> s.createMutationQuery("update Customer c set c.address.floor = 99 where c.name = 'Ravi'").executeUpdate());
t.heading("HQL: update c.address.floor = 99");
SqlLog.take().forEach(q -> t.line("sql> " + q));
t.line("rows updated: " + n + "; stored: " + Db.pgRows(url, "select address::text from customer where name='Ravi'").get(0));
assertThat(Db.pgRows(url, "select address->>'floor' from customer where name='Ravi'")).containsExactly("99");
SqlLog.clear();
List<Object[]> grouped = sf.fromSession(s -> s.createQuery("select c.address.city, count(*) from Customer c group by c.address.city order by c.address.city", Object[].class).getResultList());
t.heading("HQL: group by c.address.city");
SqlLog.take().forEach(q -> t.line("sql> " + q));
grouped.forEach(r -> t.line("row: " + r[0] + " " + r[1]));
assertThat(grouped).hasSize(2);
}
t.save();
}
}
@@ -0,0 +1,112 @@
package com.ankurm.jsoncolumns;
import com.ankurm.jsoncolumns.model.*;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.hibernate.SessionFactory;
import org.junit.jupiter.api.Test;
/** PostgreSQL's own JSONB operators in native queries: which spellings survive Hibernate's parameter parser. */
class A06_NativeJsonbTest {
record Case(String label, String sql, Map<String, Object> params) {
Case(String label, String sql) { this(label, sql, Map.of()); }
}
private static final Map<String, String> RESULT = new LinkedHashMap<>();
@Test
void nativeOperators() throws IOException {
String url = Db.newPgDatabase("jc_a06");
Transcript t = new Transcript("06-native-jsonb-operators.txt");
t.line("# " + Db.pgRows(url, "select version()").get(0).split(",")[0]);
try (SessionFactory sf = Db.pg(url, Map.of(), Product.class)) {
Seed.products(sf);
List<Case> cases = List.of(
new Case("->> text extraction", "select name from product where attributes->>'color' = 'black' order by id"),
new Case("->> with a named parameter", "select name from product where attributes->>'color' = :c order by id", Map.of("c", "black")),
new Case("-> then #>> path", "select name from product where attributes #>> '{nested,warehouse}' = 'PUNE' and id = 1"),
new Case("->> cast to int for comparison", "select name from product where (attributes->>'stock')::int > 5 order by id"),
new Case("@> containment, literal", "select name from product where tags @> '[\"office\"]' order by id"),
new Case("@> containment, parameter with cast(:p as jsonb)", "select name from product where attributes @> cast(:p as jsonb) order by id", Map.of("p", "{\"color\":\"black\"}")),
new Case("@> containment, parameter with :p::jsonb", "select name from product where attributes @> :p::jsonb order by id", Map.of("p", "{\"color\":\"black\"}")),
new Case("? key-exists, bare question mark", "select name from product where attributes ? 'color' order by id"),
new Case("? key-exists, backslash-escaped", "select name from product where attributes \\? 'color' order by id"),
new Case("? key-exists, each ? escaped: \\?\\?", "select name from product where attributes \\?\\? 'color' order by id"),
new Case("? key-exists, doubled ??", "select name from product where attributes ?? 'color' order by id"),
new Case("jsonb_exists() function instead of ?", "select name from product where jsonb_exists(attributes, 'color') order by id"),
new Case("?| any-key, doubled ??|", "select name from product where tags ??| array['home','accessories'] order by id"),
new Case("?| any-key, escaped: \\?\\?|", "select name from product where tags \\?\\?| array['home','accessories'] order by id"),
new Case("jsonb_exists_any() function instead of ?|", "select name from product where jsonb_exists_any(tags, array['home','accessories']) order by id"),
new Case("jsonb_path_exists()", "select name from product where jsonb_path_exists(attributes, '$.stock ? (@ > 5)') order by id"),
new Case("@? path operator", "select name from product where attributes @? '$.stock ? (@ > 5)' order by id"),
new Case("@? path operator, escaped: @\\?\\?", "select name from product where attributes @\\?\\? '$.stock ? (@ > 5)' order by id"),
new Case("@@ path predicate", "select name from product where attributes @@ '$.stock > 5' order by id"),
new Case("jsonb_array_elements_text in a lateral join", "select t.tag, count(*) from product p, jsonb_array_elements_text(p.tags) t(tag) group by t.tag order by t.tag"),
new Case("jsonb_set in an UPDATE (native)", "update product set attributes = jsonb_set(attributes, '{color}', to_jsonb(cast(:v as text))) where id = 3", Map.of("v", "white"))
);
for (Case c : cases) {
String outcome;
try (var s = sf.openSession()) {
var tx = s.beginTransaction();
var q = s.createNativeQuery(c.sql());
c.params().forEach(q::setParameter);
if (c.sql().startsWith("update")) outcome = "OK, " + q.executeUpdate() + " row(s) updated";
else outcome = "OK " + q.getResultList().stream().map(A06_NativeJsonbTest::show).collect(Collectors.joining("; "));
tx.commit();
} catch (RuntimeException ex) {
Throwable root = ex;
while (root.getCause() != null) root = root.getCause();
outcome = "FAILED " + ex.getClass().getSimpleName() + ": " + (root.getMessage() == null ? "" : root.getMessage().lines().findFirst().orElse(""));
}
RESULT.put(c.label(), outcome);
t.heading(c.label());
t.line("sql> " + c.sql() + (c.params().isEmpty() ? "" : " -- " + c.params()));
t.line(outcome);
}
t.heading("What does a native SELECT of a jsonb column hand back to Java?");
try (var s = sf.openSession()) {
Object raw = s.createNativeQuery("select attributes from product where id = 1").getSingleResult();
t.line("createNativeQuery(sql).getSingleResult() -> " + raw.getClass().getName());
Object typed = s.createNativeQuery("select attributes from product where id = 1", Map.class).getSingleResult();
t.line("createNativeQuery(sql, Map.class) -> " + typed.getClass().getName());
Object ent = s.createNativeQuery("select * from product where id = 1", Product.class).getSingleResult();
t.line("createNativeQuery(sql, Product.class) -> attributes.color = " + ((Product) ent).getAttributes().get("color"));
assertThat(((Product) ent).getAttributes().get("color")).isEqualTo("red");
}
t.heading("HQL equality on a jsonb column with a Java value as parameter");
try (var s = sf.openSession()) {
List<String> r = s.createQuery("select p.name from Product p where p.tags = :t", String.class)
.setParameter("t", List.of("furniture", "home")).getResultList();
t.line("where p.tags = :t with List.of(\"furniture\",\"home\") -> " + r);
assertThat(r).containsExactly("Bookshelf");
List<String> r2 = s.createQuery("select p.name from Product p where p.tags = :t", String.class)
.setParameter("t", List.of("home", "furniture")).getResultList();
t.line("where p.tags = :t with List.of(\"home\",\"furniture\") -> " + r2 + " (jsonb arrays are ordered)");
assertThat(r2).isEmpty();
}
}
t.save();
assertThat(RESULT.get("->> text extraction")).isEqualTo("OK Floor lamp; Office chair");
assertThat(RESULT.get("->> with a named parameter")).isEqualTo("OK Floor lamp; Office chair");
assertThat(RESULT.get("@> containment, parameter with cast(:p as jsonb)")).isEqualTo("OK Floor lamp; Office chair");
assertThat(RESULT.get("jsonb_exists() function instead of ?")).startsWith("OK Desk lamp");
assertThat(RESULT.get("@? path operator")).startsWith("FAILED");
assertThat(RESULT.get("@? path operator, escaped: @\\?\\?")).isEqualTo("OK Desk lamp; Bookshelf; Monitor arm");
assertThat(RESULT.get("? key-exists, bare question mark")).startsWith("FAILED");
assertThat(RESULT.get("@> containment, parameter with :p::jsonb")).startsWith("FAILED");
}
private static String show(Object row) {
if (row instanceof Object[] a) return java.util.Arrays.stream(a).map(String::valueOf).collect(Collectors.joining(", ", "(", ")"));
return String.valueOf(row);
}
}
@@ -0,0 +1,161 @@
package com.ankurm.jsoncolumns;
import com.ankurm.jsoncolumns.model.*;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.Embeddable;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.hibernate.SessionFactory;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import org.junit.jupiter.api.Test;
/** 50,000 JSONB rows, six queries, four index strategies: EXPLAIN (ANALYZE) before and after. */
class A07_IndexingTest {
/** The same table seen through a typed @Embeddable, so HQL can navigate it (and Hibernate writes the SQL). */
@Embeddable
public record Payload(String type, int userId) { }
@Entity(name = "TypedEvent") @Table(name = "event")
static class TypedEvent {
@Id Long id;
@JdbcTypeCode(SqlTypes.JSON) Payload payload;
}
private static final Pattern EXEC = Pattern.compile("Execution Time: ([0-9.]+) ms");
private final Map<String, Double> timings = new LinkedHashMap<>();
private String plan(String url, String label, String sql) {
List<String> rows = Db.pgRows(url, "explain (analyze, costs off, buffers off) " + sql);
String text = String.join("\n", rows);
Matcher m = EXEC.matcher(text);
if (!label.equals("x") && m.find()) timings.put(label, Double.parseDouble(m.group(1)));
return text;
}
private void show(Transcript t, String url, String stage, String label, String sql) {
t.heading(stage + " | " + label);
t.line("sql> " + sql);
t.line(plan(url, stage + " | " + label, sql));
}
@Test
void indexes() throws IOException {
String url = Db.newPgDatabase("jc_a07");
Db.exec(url, Db.PG_USER, "alter database jc_a07 set max_parallel_workers_per_gather = 0");
Transcript t = new Transcript("07-indexing-explain.txt");
t.line("# " + Db.pgRows(url, "select version()").get(0).split(",")[0]);
t.line("# table created by Hibernate from the Event entity; rows from generate_series; max_parallel_workers_per_gather=0 for stable plans");
t.line("# Execution Time values are from this machine and drift between runs; the plan shapes are what the tests assert.");
try (SessionFactory sf = Db.pg(url, Map.of(), Event.class)) { sf.getCache(); }
Db.exec(url, Db.PG_USER, """
insert into event(payload)
select jsonb_build_object(
'type', (array['login','logout','purchase','refund','view','click','search','share','signup','delete',
'update','create','export','import','error','warning','ping','sync','upload','download'])[1 + (g % 20)],
'userId', g % 5000,
'amount', (g % 1000) / 10.0,
'tags', case when g % 50 = 0 then '["vip","beta"]'::jsonb else '["standard"]'::jsonb end,
'device', jsonb_build_object('os', (array['android','ios','linux','windows'])[1 + (g % 4)], 'version', g % 17))
|| case when g % 500 = 0 then '{"coupon":"SPRING"}'::jsonb else '{}'::jsonb end
from generate_series(1, 50000) g""");
Db.exec(url, Db.PG_USER, "analyze event");
t.heading("data");
t.line("rows: " + Db.pgRows(url, "select count(*) from event").get(0));
t.line("sample: " + Db.pgRows(url, "select payload::text from event where id = 42").get(0));
t.line("table size (heap): " + Db.pgRows(url, "select pg_size_pretty(pg_relation_size('event'))").get(0));
assertThat(Db.pgRows(url, "select count(*) from event where payload @> '{\"userId\":42,\"type\":\"purchase\"}'")).containsExactly("10");
String q1 = "select count(*) from event where payload @> '{\"userId\":42,\"type\":\"purchase\"}'";
String q2 = "select count(*) from event where payload->>'userId' = '42'";
String q3 = "select count(*) from event where payload ? 'coupon'";
String q4 = "select count(*) from event where payload @> '{\"tags\":[\"vip\"]}'";
// ---- stage 0
String s0 = "0 no index";
show(t, url, s0, "Q1 containment @> {userId:42,type:purchase}", q1);
show(t, url, s0, "Q2 ->> equality userId = '42'", q2);
show(t, url, s0, "Q3 key exists ? 'coupon'", q3);
show(t, url, s0, "Q4 containment @> {tags:[vip]}", q4);
assertThat(plan(url, "x", q1)).contains("Seq Scan on event");
assertThat(plan(url, "x", q2)).contains("Seq Scan on event");
// ---- stage 1
String s1 = "1 GIN (payload)";
Db.exec(url, Db.PG_USER, "create index idx_event_gin on event using gin (payload)");
Db.exec(url, Db.PG_USER, "analyze event");
t.heading(s1 + " | index size");
t.line("idx_event_gin: " + Db.pgRows(url, "select pg_size_pretty(pg_relation_size('idx_event_gin'))").get(0));
show(t, url, s1, "Q1 containment @> {userId:42,type:purchase}", q1);
show(t, url, s1, "Q2 ->> equality userId = '42'", q2);
show(t, url, s1, "Q3 key exists ? 'coupon'", q3);
show(t, url, s1, "Q4 containment @> {tags:[vip]}", q4);
assertThat(plan(url, "x", q1)).contains("Bitmap Index Scan on idx_event_gin");
assertThat(plan(url, "x", q2)).contains("Seq Scan on event").doesNotContain("idx_event_gin");
assertThat(plan(url, "x", q3)).contains("Bitmap Index Scan on idx_event_gin");
// ---- stage 2
String s2 = "2 GIN (payload jsonb_path_ops)";
Db.exec(url, Db.PG_USER, "drop index idx_event_gin");
Db.exec(url, Db.PG_USER, "create index idx_event_gin_path on event using gin (payload jsonb_path_ops)");
Db.exec(url, Db.PG_USER, "analyze event");
t.heading(s2 + " | index size");
t.line("idx_event_gin_path: " + Db.pgRows(url, "select pg_size_pretty(pg_relation_size('idx_event_gin_path'))").get(0));
show(t, url, s2, "Q1 containment @> {userId:42,type:purchase}", q1);
show(t, url, s2, "Q3 key exists ? 'coupon'", q3);
show(t, url, s2, "Q4 containment @> {tags:[vip]}", q4);
assertThat(plan(url, "x", q1)).contains("Bitmap Index Scan on idx_event_gin_path");
assertThat(plan(url, "x", q3)).contains("Seq Scan on event").doesNotContain("idx_event_gin_path");
// ---- stage 3
String s3 = "3 expression b-tree";
Db.exec(url, Db.PG_USER, "create index idx_event_user_text on event ((payload->>'userId'))");
Db.exec(url, Db.PG_USER, "create index idx_event_user_int on event (((payload->>'userId')::int))");
Db.exec(url, Db.PG_USER, "analyze event");
t.heading(s3 + " | index sizes");
t.line("idx_event_user_text: " + Db.pgRows(url, "select pg_size_pretty(pg_relation_size('idx_event_user_text'))").get(0));
t.line("idx_event_user_int: " + Db.pgRows(url, "select pg_size_pretty(pg_relation_size('idx_event_user_int'))").get(0));
show(t, url, s3, "Q2 ->> equality userId = '42'", q2);
String q2int = "select count(*) from event where (payload->>'userId')::int = 42";
show(t, url, s3, "Q2b (payload->>'userId')::int = 42", q2int);
assertThat(plan(url, "x", q2)).contains("idx_event_user_text");
assertThat(plan(url, "x", q2int)).contains("idx_event_user_int");
// ---- stage 4: the SQL Hibernate itself writes
t.heading("4 does the SQL that Hibernate generates use these indexes?");
try (SessionFactory sf = Db.pg(url, Map.of("hibernate.hbm2ddl.auto", "none", "hibernate.query.hql.json_functions_enabled", true), Event.class, TypedEvent.class)) {
SqlLog.clear();
Long viaJsonValue = sf.fromSession(s -> s.createQuery("select count(*) from Event e where json_value(e.payload, '$.userId') = '42'", Long.class).getSingleResult());
String sqlJsonValue = SqlLog.take().get(0);
SqlLog.clear();
Long viaEmbeddable = sf.fromSession(s -> s.createQuery("select count(*) from TypedEvent e where e.payload.userId = 42", Long.class).getSingleResult());
String sqlTyped = SqlLog.take().get(0);
SqlLog.clear();
Long viaFunction = sf.fromSession(s -> s.createQuery("select count(*) from Event e where function('jsonb_extract_path_text', e.payload, 'userId') = '42'", Long.class).getSingleResult());
String sqlFunction = SqlLog.take().get(0);
t.line("results: json_value=" + viaJsonValue + ", embeddable=" + viaEmbeddable + ", function=" + viaFunction);
assertThat(viaJsonValue).isEqualTo(10L);
assertThat(viaEmbeddable).isEqualTo(10L);
assertThat(viaFunction).isEqualTo(10L);
show(t, url, "4", "HQL json_value(e.payload, '$.userId') = '42'", sqlJsonValue);
show(t, url, "4", "HQL e.payload.userId = 42 (typed @Embeddable)", sqlTyped);
show(t, url, "4", "HQL function('jsonb_extract_path_text', ...)", sqlFunction);
assertThat(plan(url, "x", sqlJsonValue)).contains("Seq Scan on event");
assertThat(plan(url, "x", sqlTyped)).contains("idx_event_user_int");
}
t.heading("summary of Execution Time (ms), this machine, indicative only");
timings.forEach((k, v) -> t.line(String.format("%-70s %8.3f", k, v)));
t.save();
}
}
@@ -0,0 +1,160 @@
package com.ankurm.jsoncolumns;
import com.ankurm.jsoncolumns.model.*;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.Embeddable;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.hibernate.SessionFactory;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import org.junit.jupiter.api.Test;
/** The same module against a real MySQL 8.0 server: DDL, round trip, dirty checking, HQL functions, native JSON functions, indexes. */
class A08_MySqlTest {
@Embeddable
public record Address(String city, String zip, int floor) { }
@Entity(name = "Customer") @Table(name = "customer")
static class Customer {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
String name;
@JdbcTypeCode(SqlTypes.JSON) Address address;
Customer() { }
Customer(String name, Address address) { this.name = name; this.address = address; }
}
private static final Map<String, String> OUTCOMES = new LinkedHashMap<>();
@Test
void mysql() throws IOException {
Transcript t = new Transcript("08-mysql.txt");
try { body(t); } finally { t.save(); }
}
private void body(Transcript t) {
String url = Db.newMysqlDatabase("jc_a08");
t.line("# MySQL " + Db.mysqlRows(url, "select version()").get(0));
t.line("# Hibernate " + org.hibernate.Version.getVersionString());
t.heading("CREATE TABLE as Hibernate emits it for MySQL");
for (String l : Db.createScript(Db.props(url, Db.MYSQL_USER, Map.of()), Product.class)) if (l.startsWith("create table")) t.line(l);
try (SessionFactory sf = Db.mysql(url, Map.of("hibernate.query.hql.json_functions_enabled", true), Product.class, Event.class, Customer.class)) {
t.heading("column types in information_schema");
Db.mysqlRows(url, "select concat(column_name, ' ', column_type) from information_schema.columns where table_schema='jc_a08' and table_name='product' and column_name in ('attributes','tags','dimensions','specs') order by column_name").forEach(t::line);
assertThat(Db.mysqlRows(url, "select column_type from information_schema.columns where table_schema='jc_a08' and table_name='product' and column_name='attributes'")).containsExactly("json");
Seed.products(sf);
t.heading("what MySQL stored (it normalises: keys sorted by length, then alphabetically)");
Db.mysqlRows(url, "select attributes from product where id = 1").forEach(l -> t.line("attributes = " + l));
Db.mysqlRows(url, "select dimensions from product where id = 1").forEach(l -> t.line("dimensions = " + l));
assertThat(Db.mysqlRows(url, "select dimensions from product where id = 1")).containsExactly("{\"depthMm\": 180, \"widthMm\": 180, \"heightMm\": 360}");
t.heading("dirty checking on MySQL");
Long id = Db.mysqlRows(url, "select min(id) from product").stream().map(Long::valueOf).findFirst().orElseThrow();
SqlLog.clear();
sf.inTransaction(s -> s.find(Product.class, id));
long idle = SqlLog.take().stream().filter(q -> q.startsWith("update")).count();
SqlLog.clear();
sf.inTransaction(s -> s.find(Product.class, id).getAttributes().put("color", "teal"));
long mutated = SqlLog.take().stream().filter(q -> q.startsWith("update")).count();
t.line("load, change nothing: " + idle + " UPDATE; attributes.put(\"color\",\"teal\"): " + mutated + " UPDATE");
t.line("stored color: " + Db.mysqlRows(url, "select attributes->>'$.color' from product where id = " + id).get(0));
assertThat(idle).isZero();
assertThat(mutated).isEqualTo(1);
assertThat(Db.mysqlRows(url, "select attributes->>'$.color' from product where id = " + id)).containsExactly("teal");
t.heading("HQL JSON functions on MySQL (same statements as 04-hql-json-functions-postgresql.txt)");
Map<String, String> cases = A04_HqlJsonFunctionsTest.cases();
cases.remove("function('jsonb_extract_path_text')");
HqlRunner.run(sf, cases, t, OUTCOMES);
t.heading("native JSON functions and operators (MySQL spellings)");
String[][] natives = {
{"->> text extraction", "select name from product where attributes->>'$.color' = 'black' order by id"},
{"JSON_CONTAINS(tags, '\"office\"')", "select name from product where json_contains(tags, '\"office\"') order by id"},
{"'office' MEMBER OF (tags)", "select name from product where 'office' member of (tags) order by id"},
{"JSON_OVERLAPS(tags, '[\"home\",\"accessories\"]')", "select name from product where json_overlaps(tags, '[\"home\",\"accessories\"]') order by id"},
{"JSON_CONTAINS_PATH(attributes, 'one', '$.nested.warehouse')", "select count(*) from product where json_contains_path(attributes, 'one', '$.nested.warehouse')"},
};
try (var s = sf.openSession()) {
for (String[] n : natives) {
List<?> rows = s.createNativeQuery(n[1]).getResultList();
String r = rows.stream().map(HqlRunner::show).collect(java.util.stream.Collectors.joining("; "));
t.line(n[0] + " -> " + r);
OUTCOMES.put("native " + n[0], r);
}
}
assertThat(OUTCOMES.get("native 'office' MEMBER OF (tags)")).isEqualTo("Desk lamp; Office chair; Monitor arm");
assertThat(OUTCOMES.get("native JSON_OVERLAPS(tags, '[\"home\",\"accessories\"]')")).isEqualTo("Floor lamp; Bookshelf; Monitor arm");
t.heading("@Embeddable as JSON on MySQL");
sf.inTransaction(s -> {
s.persist(new Customer("Asha", new Address("Pune", "411001", 3)));
s.persist(new Customer("Ravi", new Address("Mumbai", "400001", 12)));
});
SqlLog.clear();
List<String> pune = sf.fromSession(s -> s.createQuery("select c.name from Customer c where c.address.city = 'Pune'", String.class).getResultList());
SqlLog.take().forEach(q -> t.line("sql> " + q));
t.line("result: " + pune);
assertThat(pune).containsExactly("Asha");
}
// outcomes that matter for the post
assertThat(OUTCOMES.get("json_value in where")).isEqualTo("OK Floor lamp; Office chair");
t.heading("indexing 50,000 rows on MySQL");
indexing(url, t);
}
private static String explain(String url, String sql) {
String r = Db.mysqlRows(url, "explain " + sql).get(0);
String[] c = r.split(" \\| ");
// columns: id, select_type, table, partitions, type, possible_keys, key, key_len, ref, rows, filtered, Extra
return "type=" + c[4] + " key=" + c[6] + " rows=" + c[9] + " extra=" + c[11];
}
private static void indexing(String url, Transcript t) {
Db.exec(url, Db.MYSQL_USER, "set session cte_max_recursion_depth = 100000");
try (var cn = java.sql.DriverManager.getConnection(url, Db.MYSQL_USER, ""); var st = cn.createStatement()) {
st.execute("set session cte_max_recursion_depth = 100000");
st.execute("delete from event");
st.execute("""
insert into event(payload)
with recursive g(n) as (select 1 union all select n + 1 from g where n < 50000)
select json_object('type', elt(1 + (n % 4), 'login', 'purchase', 'view', 'click'),
'userId', n % 5000,
'tags', if(n % 50 = 0, json_array('vip', 'beta'), json_array('standard')))
from g""");
st.execute("analyze table event");
} catch (java.sql.SQLException e) { throw new IllegalStateException(e); }
t.line("rows: " + Db.mysqlRows(url, "select count(*) from event").get(0));
String q1 = "select count(*) from event where payload->>'$.userId' = '42'";
String q2 = "select count(*) from event where cast(payload->>'$.userId' as unsigned) = 42";
String q3 = "select count(*) from event where 'vip' member of (payload->'$.tags')";
t.heading("no index");
for (String q : List.of(q1, q2, q3)) { t.line("sql> " + q); t.line(explain(url, q)); }
assertThat(explain(url, q2)).startsWith("type=ALL");
Db.exec(url, Db.MYSQL_USER, "create index idx_user on event ((cast(payload->>'$.userId' as unsigned)))");
Db.exec(url, Db.MYSQL_USER, "create index idx_tags on event ((cast(payload->'$.tags' as char(10) array)))");
Db.exec(url, Db.MYSQL_USER, "analyze table event");
t.heading("functional index on cast(payload->>'$.userId' as unsigned), multi-valued index on tags");
for (String q : List.of(q1, q2, q3)) { t.line("sql> " + q); t.line(explain(url, q)); }
t.line("count via index: " + Db.mysqlRows(url, q2).get(0) + " / vip rows: " + Db.mysqlRows(url, q3).get(0));
assertThat(explain(url, q2)).startsWith("type=ref key=idx_user");
assertThat(explain(url, q3)).contains("key=idx_tags");
assertThat(explain(url, q1)).startsWith("type=ALL");
assertThat(Db.mysqlRows(url, q2)).containsExactly("10");
assertThat(Db.mysqlRows(url, q3)).containsExactly("1000");
}
}
@@ -0,0 +1,116 @@
package com.ankurm.jsoncolumns;
import com.ankurm.jsoncolumns.model.*;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.hibernate.SessionFactory;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.type.format.jackson.Jackson3JsonFormatMapper;
import org.junit.jupiter.api.Test;
import tools.jackson.databind.PropertyNamingStrategies;
import tools.jackson.databind.json.JsonMapper;
/** In-process FormatMapper checks on this module's own classpath (Jackson 3 only). The classpath matrix is scripts/format-mapper-matrix.sh. */
class A09_FormatMapperTest {
/** Same idea as Dimensions, but tolerant of keys it does not know (the annotation lives in jackson-annotations, shared by Jackson 2 and 3). */
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
public record Tolerant(int widthMm) { }
@jakarta.persistence.Entity(name = "Tol") @jakarta.persistence.Table(name = "tol")
static class Tol {
@jakarta.persistence.Id Long id;
@org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON) Tolerant dims;
}
@Test
void jackson3Only() throws IOException {
String url = Db.newPgDatabase("jc_a09");
Transcript t = new Transcript("09-format-mapper-inprocess.txt");
try {
body(t, url);
} finally {
t.save();
}
}
private void body(Transcript t, String url) {
t.heading("which FormatMapper with only Jackson 3 on the classpath, nothing configured");
try (SessionFactory sf = Db.pg(url, Map.of(), Product.class)) {
Class<?> c = ((SessionFactoryImplementor) sf).getTypeConfiguration().getJsonFormatMapper().getClass();
t.line("getJsonFormatMapper() -> " + c.getName());
assertThat(c).isEqualTo(Jackson3JsonFormatMapper.class);
t.line("Jackson 2 present? " + present("com.fasterxml.jackson.databind.ObjectMapper") + "; Jackson 3 present? " + present("tools.jackson.databind.json.JsonMapper"));
assertThat(present("com.fasterxml.jackson.databind.ObjectMapper")).isFalse();
}
t.heading("schema evolution: stored JSON that does not match the record");
try (SessionFactory sf = Db.pg(url, Map.of(), Product.class)) {
Db.exec(url, Db.PG_USER, "insert into product(name, dimensions) values ('extra-key', '{\"widthMm\":1,\"heightMm\":2,\"depthMm\":3,\"colour\":\"red\"}')");
Db.exec(url, Db.PG_USER, "insert into product(name, dimensions) values ('missing-key', '{\"widthMm\":1}')");
Db.exec(url, Db.PG_USER, "insert into product(name, dimensions) values ('wrong-type', '{\"widthMm\":\"wide\",\"heightMm\":2,\"depthMm\":3}')");
Db.exec(url, Db.PG_USER, "insert into product(name, dimensions) values ('not-an-object', '[1,2,3]')");
for (String n : List.of("extra-key", "missing-key", "wrong-type", "not-an-object")) {
String outcome;
try {
Product p = sf.fromSession(s -> s.createQuery("from Product where name = :n", Product.class).setParameter("n", n).getSingleResult());
outcome = "loaded " + p.getDimensions();
} catch (RuntimeException e) {
Throwable r = e;
while (r.getCause() != null) r = r.getCause();
String m = r.getMessage() == null ? "" : r.getMessage().lines().findFirst().orElse("");
outcome = "FAILED " + r.getClass().getName() + ": " + (m.length() > 200 ? m.substring(0, 200) + "..." : m);
}
t.line(String.format("%-13s -> %s", n, outcome));
if (n.equals("missing-key")) assertThat(outcome).isEqualTo("loaded Dimensions[widthMm=1, heightMm=0, depthMm=0]");
else assertThat(outcome).startsWith("FAILED");
}
}
t.heading("the fix for extra keys: @JsonIgnoreProperties(ignoreUnknown = true) on the record");
String urlT = Db.newPgDatabase("jc_a09t");
try (SessionFactory sf = Db.pg(urlT, Map.of(), Tol.class)) {
Db.exec(urlT, Db.PG_USER, "insert into tol(id, dims) values (1, '{\"widthMm\":7,\"colour\":\"red\"}')");
Tol r = sf.fromSession(s -> s.find(Tol.class, 1L));
t.line("stored {\"widthMm\":7,\"colour\":\"red\"} -> loaded " + r.dims);
assertThat(r.dims).isEqualTo(new Tolerant(7));
}
t.heading("hibernate.type.json_format_mapper = your own Jackson 3 JsonMapper (snake_case)");
JsonMapper snake = JsonMapper.builder().propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE).build();
Map<String, Object> extra = Map.of("hibernate.type.json_format_mapper", new Jackson3JsonFormatMapper(snake));
String url2 = Db.newPgDatabase("jc_a09b");
try (SessionFactory sf = Db.pg(url2, extra, Product.class)) {
Product p = new Product("snake", null, null, new Dimensions(1, 2, 3), new Specs("tin", 0.5, List.of()));
sf.inTransaction(s -> s.persist(p));
t.line("dimensions = " + Db.pgRows(url2, "select dimensions::text from product").get(0));
t.line("specs = " + Db.pgRows(url2, "select specs::text from product").get(0));
assertThat(Db.pgRows(url2, "select dimensions::text from product")).containsExactly("{\"depth_mm\": 3, \"width_mm\": 1, \"height_mm\": 2}");
Product back = sf.fromSession(s -> s.find(Product.class, p.getId()));
assertThat(back.getDimensions()).isEqualTo(new Dimensions(1, 2, 3));
t.line("read back = " + back.getDimensions());
}
t.heading("java.time inside a JSON value with the default mapper");
Map<String, Object> m = new LinkedHashMap<>();
m.put("at", Instant.parse("2026-10-30T10:15:30Z"));
try (SessionFactory sf = Db.pg(Db.newPgDatabase("jc_a09c"), Map.of(), Product.class)) {
sf.inTransaction(s -> s.persist(new Product("time", m, null, null, null)));
String stored = Db.pgRows(Db.PG_BASE + "jc_a09c", "select attributes::text from product").get(0);
t.line("Map.of(\"at\", Instant) stored as " + stored);
Map<String, Object> back = sf.fromSession(s -> s.createQuery("from Product", Product.class).getSingleResult().getAttributes());
t.line("read back as " + back + " (" + back.get("at").getClass().getSimpleName() + ")");
assertThat(back.get("at")).isNotInstanceOf(Instant.class);
}
}
private static boolean present(String cn) {
try { Class.forName(cn); return true; } catch (ClassNotFoundException | LinkageError e) { return false; }
}
}
@@ -0,0 +1,83 @@
package com.ankurm.jsoncolumns;
import com.ankurm.jsoncolumns.model.*;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.List;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.type.format.FormatMapper;
import org.junit.jupiter.api.Test;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import tools.jackson.databind.json.JsonMapper;
/** The same mappings inside Spring Boot 4.1.2 + Spring Data JPA: repositories, @Query, and which JsonMapper Hibernate ends up with. */
class A11_SpringBootTest {
private static ConfigurableApplicationContext start(String db, String... extra) {
String url = Db.newPgDatabase(db);
String[] base = {"--spring.datasource.url=" + url, "--spring.datasource.username=" + Db.PG_USER, "--spring.datasource.password=",
"--spring.jpa.hibernate.ddl-auto=create", "--spring.jpa.open-in-view=false",
"--spring.jpa.properties.hibernate.query.hql.json_functions_enabled=true", "--logging.level.root=ERROR",
"--spring.main.banner-mode=off"};
String[] all = java.util.stream.Stream.concat(java.util.Arrays.stream(base), java.util.Arrays.stream(extra)).toArray(String[]::new);
return new SpringApplicationBuilder(JsonColumnsApplication.class).run(all);
}
private static JsonMapper hibernateMapper(FormatMapper fm) throws ReflectiveOperationException {
Field f = fm.getClass().getDeclaredField("jsonMapper");
f.setAccessible(true);
return (JsonMapper) f.get(fm);
}
@Test
void springBoot() throws Exception {
Transcript t = new Transcript("11-spring-boot.txt");
try {
body(t);
} finally {
t.save();
}
}
private void body(Transcript t) throws Exception {
t.heading("default Boot wiring");
try (ConfigurableApplicationContext ctx = start("jc_a11")) {
ProductRepository repo = ctx.getBean(ProductRepository.class);
repo.saveAll(List.of(
Seed.product("Desk lamp", "red", 12, 19.9, List.of("lighting", "office"), 180, "steel", List.of("CE")),
Seed.product("Floor lamp", "black", 3, 89.5, List.of("lighting", "home"), 300, "steel", List.of("CE")),
Seed.product("Office chair", "black", 0, 149.0, List.of("office", "furniture"), 600, "mesh", List.of())));
t.line("JsonMapper bean from Boot : " + ctx.getBean(JsonMapper.class).getClass().getName());
FormatMapper fm = ctx.getBean(jakarta.persistence.EntityManagerFactory.class)
.unwrap(SessionFactoryImplementor.class).getTypeConfiguration().getJsonFormatMapper();
t.line("Hibernate FormatMapper : " + fm.getClass().getName());
boolean same = hibernateMapper(fm) == ctx.getBean(JsonMapper.class);
t.line("same JsonMapper instance? : " + same);
assertThat(same).isFalse();
t.line("native @> (findByAttributesContaining {\"color\":\"black\"}): " + repo.findByAttributesContaining("{\"color\":\"black\"}").stream().map(Product::getName).toList());
t.line("HQL json_value (findByColor black) : " + repo.findByColor("black").stream().map(Product::getName).toList());
t.line("derived query findByDimensions(new Dimensions(300,600,300)): " + repo.findByDimensions(new Dimensions(300, 600, 300)).stream().map(Product::getName).toList());
assertThat(repo.findByAttributesContaining("{\"color\":\"black\"}")).extracting(Product::getName).containsExactly("Floor lamp", "Office chair");
assertThat(repo.findByColor("black")).extracting(Product::getName).containsExactly("Floor lamp", "Office chair");
assertThat(repo.findByDimensions(new Dimensions(300, 600, 300))).extracting(Product::getName).containsExactly("Floor lamp");
int n = ctx.getBean(org.springframework.transaction.support.TransactionTemplate.class).execute(s -> repo.repaint(1L, "green"));
t.line("@Modifying json_set repaint(1, \"green\") -> " + n + " row; color now " + repo.findById(1L).orElseThrow().getAttributes().get("color"));
assertThat(repo.findById(1L).orElseThrow().getAttributes().get("color")).isEqualTo("green");
}
t.heading("profile shared-mapper: HibernatePropertiesCustomizer hands Boot's JsonMapper to Hibernate");
try (ConfigurableApplicationContext ctx = start("jc_a11b", "--spring.profiles.active=shared-mapper")) {
FormatMapper fm = ctx.getBean(jakarta.persistence.EntityManagerFactory.class)
.unwrap(SessionFactoryImplementor.class).getTypeConfiguration().getJsonFormatMapper();
boolean same = hibernateMapper(fm) == ctx.getBean(JsonMapper.class);
t.line("same JsonMapper instance? : " + same);
assertThat(same).isTrue();
}
}
}
@@ -0,0 +1,126 @@
package com.ankurm.jsoncolumns;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.hibernate.SessionFactory;
import org.hibernate.boot.MetadataSources;
import org.hibernate.boot.registry.StandardServiceRegistry;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
/**
* Plain Hibernate bootstrap (no Spring) against the PostgreSQL and MySQL servers the scripts start.
* Addresses come from PG_URL / PG_USER and MYSQL_URL / MYSQL_USER (see scripts/start-databases.sh).
*/
public final class Db {
public static final String PG_BASE = System.getenv().getOrDefault("PG_URL", "jdbc:postgresql://127.0.0.1:55432/");
public static final String PG_USER = System.getenv().getOrDefault("PG_USER", "postgres");
public static final String MYSQL_BASE = System.getenv().getOrDefault("MYSQL_URL", "jdbc:mysql://127.0.0.1:53306/");
public static final String MYSQL_USER = System.getenv().getOrDefault("MYSQL_USER", "root");
private Db() { }
/** Drops and recreates database {@code name} on the PostgreSQL server; returns its JDBC URL. */
public static String newPgDatabase(String name) {
try (Connection cn = DriverManager.getConnection(PG_BASE + "postgres", PG_USER, "");
Statement st = cn.createStatement()) {
st.execute("drop database if exists " + name + " with (force)");
st.execute("create database " + name);
} catch (SQLException e) { throw new IllegalStateException(e); }
return PG_BASE + name;
}
public static String newMysqlDatabase(String name) {
try (Connection cn = DriverManager.getConnection(MYSQL_BASE, MYSQL_USER, "");
Statement st = cn.createStatement()) {
st.execute("drop database if exists " + name);
st.execute("create database " + name);
} catch (SQLException e) { throw new IllegalStateException(e); }
return MYSQL_BASE + name;
}
public static Map<String, Object> props(String url, String user, Map<String, Object> extra) {
Map<String, Object> p = new LinkedHashMap<>();
p.put("hibernate.connection.url", url);
p.put("hibernate.connection.username", user);
p.put("hibernate.connection.password", "");
p.put("hibernate.hbm2ddl.auto", "create");
p.put("hibernate.session_factory.statement_inspector", SqlLog.class.getName());
p.putAll(extra);
return p;
}
public static SessionFactory sessionFactory(Map<String, Object> props, Class<?>... entities) {
StandardServiceRegistry reg = new StandardServiceRegistryBuilder().applySettings(props).build();
MetadataSources ms = new MetadataSources(reg);
for (Class<?> c : entities) ms.addAnnotatedClass(c);
return ms.buildMetadata().buildSessionFactory();
}
public static SessionFactory pg(String url, Map<String, Object> extra, Class<?>... entities) {
return sessionFactory(props(url, PG_USER, extra), entities);
}
public static SessionFactory mysql(String url, Map<String, Object> extra, Class<?>... entities) {
return sessionFactory(props(url, MYSQL_USER, extra), entities);
}
/** The CREATE TABLE script Hibernate emits for these entities, via JPA script generation; no table is created. */
public static List<String> createScript(Map<String, Object> props, Class<?>... entities) {
try {
Path tmp = Files.createTempFile("ddl", ".sql");
Map<String, Object> p = new LinkedHashMap<>(props);
p.put("hibernate.hbm2ddl.auto", "none");
p.put("jakarta.persistence.schema-generation.scripts.action", "create");
p.put("jakarta.persistence.schema-generation.scripts.create-target", tmp.toString());
sessionFactory(p, entities).close();
List<String> lines = new ArrayList<>();
for (String l : Files.readAllLines(tmp)) if (!l.isBlank()) lines.add(l);
Files.delete(tmp);
return lines;
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
/** Runs a statement over plain JDBC and renders every row as pipe-separated text. */
public static List<String> rows(String url, String user, String sql) {
List<String> out = new ArrayList<>();
try (Connection cn = DriverManager.getConnection(url, user, "");
Statement st = cn.createStatement()) {
if (!st.execute(sql)) return out;
try (ResultSet rs = st.getResultSet()) {
ResultSetMetaData md = rs.getMetaData();
while (rs.next()) {
StringBuilder sb = new StringBuilder();
for (int i = 1; i <= md.getColumnCount(); i++) {
if (i > 1) sb.append(" | ");
sb.append(rs.getString(i));
}
out.add(sb.toString());
}
}
} catch (SQLException e) { throw new IllegalStateException(sql + " -> " + e.getMessage(), e); }
return out;
}
public static List<String> pgRows(String url, String sql) { return rows(url, PG_USER, sql); }
public static List<String> mysqlRows(String url, String sql) { return rows(url, MYSQL_USER, sql); }
public static void exec(String url, String user, String sql) {
try (Connection cn = DriverManager.getConnection(url, user, ""); Statement st = cn.createStatement()) {
st.execute(sql);
} catch (SQLException e) { throw new IllegalStateException(sql + " -> " + e.getMessage(), e); }
}
}
@@ -0,0 +1,67 @@
package com.ankurm.jsoncolumns;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.hibernate.SessionFactory;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.type.SqlTypes;
/**
* A main() run by scripts/format-mapper-matrix.sh under different classpaths: which JSON FormatMapper does Hibernate
* pick, and does a round trip (Map + record holding an Instant) survive? Argument 1 (optional) = value for
* hibernate.type.json_format_mapper.
*/
public final class FormatMapperProbe {
public record Stamp(String label, Instant at) { }
@Entity(name = "Doc") @Table(name = "doc")
public static class Doc {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
@JdbcTypeCode(SqlTypes.JSON) Map<String, Object> attrs;
@JdbcTypeCode(SqlTypes.JSON) Stamp stamp;
}
private FormatMapperProbe() { }
private static String root(Throwable t) {
Throwable r = t;
while (r.getCause() != null) r = r.getCause();
String m = r.getMessage() == null ? "" : r.getMessage().lines().findFirst().orElse("");
return r.getClass().getName() + ": " + (m.length() > 420 ? m.substring(0, 420) + "..." : m);
}
public static void main(String[] args) {
String url = Db.newPgDatabase("jc_probe");
Map<String, Object> extra = new LinkedHashMap<>();
if (args.length > 0 && !args[0].isBlank()) extra.put("hibernate.type.json_format_mapper", args[0]);
System.out.println("setting : hibernate.type.json_format_mapper = " + (extra.isEmpty() ? "(not set)" : args[0]));
System.out.println("on classpath: jackson2=" + present("com.fasterxml.jackson.databind.ObjectMapper")
+ " jackson3=" + present("tools.jackson.databind.json.JsonMapper")
+ " jakarta.json=" + present("jakarta.json.bind.Jsonb"));
try (SessionFactory sf = Db.pg(url, extra, Doc.class)) {
System.out.println("mapper : " + ((SessionFactoryImplementor) sf).getTypeConfiguration().getJsonFormatMapper().getClass().getName());
Doc d = new Doc();
d.attrs = new LinkedHashMap<>(Map.of("n", 1, "list", List.of("a", "b")));
d.stamp = new Stamp("boot", Instant.parse("2026-10-30T10:15:30Z"));
sf.inTransaction(s -> s.persist(d));
System.out.println("stored : " + Db.pgRows(url, "select attrs::text || ' ; ' || stamp::text from doc").get(0));
Doc back = sf.fromSession(s -> s.find(Doc.class, d.id));
System.out.println("read back : " + back.attrs + " ; " + back.stamp);
} catch (Throwable t) {
System.out.println("FAILED : " + root(t));
}
}
private static boolean present(String cn) {
try { Class.forName(cn); return true; } catch (ClassNotFoundException | LinkageError e) { return false; }
}
}
@@ -0,0 +1,48 @@
package com.ankurm.jsoncolumns;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.hibernate.SessionFactory;
/** Runs a labelled set of HQL statements, writes hql / generated SQL / outcome to the transcript, returns label -> outcome. */
final class HqlRunner {
private HqlRunner() { }
static Map<String, String> run(SessionFactory sf, Map<String, String> cases, Transcript t, Map<String, String> outcomes) {
for (var e : cases.entrySet()) {
String hql = e.getValue();
SqlLog.clear();
String outcome;
try (var s = sf.openSession()) {
var tx = s.beginTransaction();
if (hql.startsWith("update")) {
int n = s.createMutationQuery(hql).executeUpdate();
outcome = "OK, " + n + " row(s) updated";
} else {
List<?> rows = s.createQuery(hql, Object.class).getResultList();
outcome = "OK " + rows.stream().map(HqlRunner::show).collect(Collectors.joining("; "));
}
tx.commit();
} catch (RuntimeException ex) {
Throwable root = ex;
while (root.getCause() != null) root = root.getCause();
outcome = "FAILED " + ex.getClass().getSimpleName() + ": " + first(root.getMessage() == null ? ex.getMessage() : root.getMessage());
}
List<String> sql = SqlLog.take();
t.heading(e.getKey());
t.line("hql> " + hql);
if (!sql.isEmpty()) t.line("sql> " + sql.get(sql.size() - 1).replaceAll("\\s+", " "));
t.line(outcome);
outcomes.put(e.getKey(), outcome);
}
return outcomes;
}
static String show(Object row) {
if (row instanceof Object[] a) return java.util.Arrays.stream(a).map(String::valueOf).collect(Collectors.joining(", ", "(", ")"));
return String.valueOf(row);
}
static String first(String m) { return m == null ? "" : m.lines().findFirst().orElse(""); }
}
@@ -0,0 +1,32 @@
package com.ankurm.jsoncolumns;
import com.ankurm.jsoncolumns.model.*;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.hibernate.SessionFactory;
/** Five products every query test uses, so the expected results can be read off the data. */
final class Seed {
private Seed() { }
static Product product(String name, String color, int stock, double price, List<String> tags, int w, String material, List<String> certs) {
Map<String, Object> a = new LinkedHashMap<>();
a.put("color", color);
a.put("stock", stock);
a.put("price", price);
a.put("nested", new LinkedHashMap<>(Map.of("warehouse", "PUNE", "aisles", new ArrayList<>(List.of(3, 7)))));
return new Product(name, a, new ArrayList<>(tags), new Dimensions(w, w * 2, w), new Specs(material, w / 100.0, certs));
}
static void products(SessionFactory sf) {
sf.inTransaction(s -> {
s.persist(product("Desk lamp", "red", 12, 19.9, List.of("lighting", "office"), 180, "steel", List.of("CE", "RoHS")));
s.persist(product("Floor lamp", "black", 3, 89.5, List.of("lighting", "home"), 300, "steel", List.of("CE")));
s.persist(product("Office chair", "black", 0, 149.0, List.of("office", "furniture"), 600, "mesh", List.of()));
s.persist(product("Bookshelf", "oak", 7, 120.0, List.of("furniture", "home"), 900, "wood", List.of("FSC")));
s.persist(product("Monitor arm", "silver", 25, 45.0, List.of("office", "accessories"), 150, "aluminium", List.of("CE", "RoHS", "UL")));
});
}
}
@@ -0,0 +1,17 @@
package com.ankurm.jsoncolumns;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.hibernate.resource.jdbc.spi.StatementInspector;
/** Hibernate statement inspector that records every SQL string. Registered by {@link Db} via a Hibernate property. */
public class SqlLog implements StatementInspector {
private static final List<String> LOG = new CopyOnWriteArrayList<>();
@Override
public String inspect(String sql) { LOG.add(sql); return sql; }
public static void clear() { LOG.clear(); }
public static List<String> take() { List<String> copy = List.copyOf(LOG); LOG.clear(); return copy; }
}
@@ -0,0 +1,33 @@
package com.ankurm.jsoncolumns;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/** Collects what a test observed and writes it to {@code output/NN-name.txt}; the tests assert the same facts. */
public final class Transcript {
private final StringBuilder sb = new StringBuilder();
private final String file;
public Transcript(String file) { this.file = file; }
public Transcript line(String s) { sb.append(s).append('\n'); return this; }
public Transcript heading(String s) { return line("").line("== " + s); }
/** A step: the label, then every SQL statement Hibernate issued during it. */
public Transcript step(String label, List<String> sql) {
line("-- " + label);
if (sql.isEmpty()) line(" (no SQL)");
for (String s : sql) line(" sql> " + s.replaceAll("\\s+", " ").trim());
return this;
}
public void save() throws IOException {
Path out = Path.of("output", file);
Files.createDirectories(out.getParent());
Files.writeString(out, sb.toString(), StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,6 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder><pattern>%-5level %logger{20} - %msg%n</pattern></encoder>
</appender>
<root level="ERROR"><appender-ref ref="STDOUT"/></root>
</configuration>