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:
+93
@@ -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>
|
||||
Reference in New Issue
Block a user