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:
@@ -0,0 +1,25 @@
|
||||
# json-columns findings (Hibernate 7.4.14.Final, PostgreSQL 18.6, MySQL 8.0.46)
|
||||
|
||||
Learned by running the module tests (output/NN-*.txt are the evidence).
|
||||
|
||||
- PostgreSQL: SqlTypes.JSON emits `jsonb` (A01). `@JdbcType(PostgreSQLJsonPGObjectJsonType)` still emits jsonb DDL; use columnDefinition = "json" to get json.
|
||||
- Jackson choice: Jackson 3 only -> Jackson3JsonFormatMapper; both present -> Jackson 2 JacksonJsonFormatMapper wins silently (fails on Instant without jsr310); none -> startup HibernateException. `hibernate.type.json_format_mapper=jackson|jackson3|jsonb|instance` (A09, output/09).
|
||||
- Jackson 3 mapper as configured by Hibernate FAILS on unknown properties; missing keys default silently.
|
||||
- HQL json_value/json_query/json_exists/json_object/json_table/aggregates need `hibernate.query.hql.json_functions_enabled=true`; json_set in UPDATE ran without it (only modifier tried) (A04).
|
||||
- Native `?` operator: only `\?\?` works; use jsonb_exists() or `@>`; `:p::jsonb` fails, use cast(:p as jsonb) (A06).
|
||||
- Dirty checking: in-place Map/List/POJO/record changes detected (snapshot = serialise+parse, Objects.equals); POJO without equals() => UPDATE on every flush; BigDecimal in Map => UPDATE right after INSERT; all columns rewritten unless @DynamicUpdate (A03, output/12).
|
||||
- Indexing, 50,000 rows: Q1 @> 10.2 ms seq scan -> 0.68 ms GIN -> 0.18 ms jsonb_path_ops; Q4 20.1 -> ~2.9 ms; path_ops cannot serve `?`; only a b-tree on the exact expression serves `->>` equality (A07, output/07). MySQL: functional and multi-valued indexes needed the exact expression (A08).
|
||||
- hypersistence-utils: @TypeDef/@Type(type=) absent in 7.4.14; com.vladmihalcea hibernate-types-60 last 2.21.1 (2022-12); hypersistence-utils-hibernate-73 3.16.0 (2026-09-20) JsonType works on 7.4.14 (A10, output/13).
|
||||
- Spring: HibernatePropertiesCustomizer lives in org.springframework.boot.hibernate.autoconfigure (Boot 4); Boot JsonMapper is not Hibernate's by default.
|
||||
|
||||
## Sandbox notes
|
||||
Zonky embedded PostgreSQL 18.6 binaries from Maven Central (Central 429s: retry); MySQL via apt. /dev/null was a regular file (chmod 666). org.hibernate.tool.hbm2ddl.SchemaExport not in hibernate-core 7 (use JPA script generation).
|
||||
|
||||
## Not tested
|
||||
MariaDB, Oracle, SQL Server, DB2, HANA, H2 JSON; jsonb format mapper (Yasson); SqlTypes.JSON_ARRAY; TOAST; GIN write cost; json (not jsonb) query behaviour; PG < 18.
|
||||
|
||||
## Incidental defects
|
||||
None in existing modules observed beyond what is listed above.
|
||||
|
||||
## Publishing
|
||||
Post ID, permalink and commit sha: see final report.
|
||||
@@ -0,0 +1,65 @@
|
||||
# json-columns
|
||||
|
||||
Companion module for the ankurm.com article **JSON Columns in Hibernate 7: `@JdbcTypeCode(SqlTypes.JSON)` and Querying JSONB on PostgreSQL**
|
||||
(https://ankurm.com/hibernate-json-column-jdbctypecode-jsonb-postgresql/).
|
||||
Every claim in the article comes from a test or script here; each writes what it observed to `output/`.
|
||||
The tests run against a real PostgreSQL 18 and a real MySQL 8.0 server started without Docker (see Quickstart).
|
||||
|
||||
## Versions
|
||||
|
||||
| Component | Version | Notes |
|
||||
|---|---|---|
|
||||
| Hibernate ORM | `7.4.14.Final` | newest 7.x GA on Maven Central on 2026-10-30; overrides the `7.4.13.Final` Spring Boot 4.1.2 manages |
|
||||
| Spring Boot | `4.1.2` | parent POM; Spring Data JPA `4.1.2` |
|
||||
| Jackson | `3.1.7` (`tools.jackson`) | what Boot 4.1.2 manages; Jackson 2 `2.22.4` only in the `jackson2` profile |
|
||||
| PostgreSQL / driver | `18.6` / `42.7.14` | `embedded-postgres-binaries-linux-amd64` 18.6.0 from Maven Central, no Docker |
|
||||
| MySQL / driver | `8.0.46` / Connector/J `9.7.0` | Ubuntu 24.04 `mysql-server-core-8.0` package |
|
||||
| hypersistence-utils | `hypersistence-utils-hibernate-73` `3.16.0` | `hypersistence` profile only (A10) |
|
||||
| Java | `25` (Temurin 25.0.5+8) | |
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
./scripts/start-databases.sh # PostgreSQL on 55432 and MySQL on 53306 (needs root: both servers refuse to run as root, so it makes two OS users)
|
||||
./scripts/run-all.sh # versions + mvn clean test + hypersistence runs + FormatMapper matrix; regenerates everything in output/
|
||||
```
|
||||
|
||||
Point the tests at other servers with `PG_URL`, `PG_USER`, `MYSQL_URL`, `MYSQL_USER` (defaults in `src/test/java/com/ankurm/jsoncolumns/Db.java`).
|
||||
Timings in `output/07-indexing-explain.txt` drift between runs; plan shapes and row counts are asserted.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `src/main/java/.../model` | `Product` (Map, List, record, POJO columns), `Dimensions` (record), `Specs` (POJO with `equals()`), `Event` (indexing table), `ProductRepository`, `SharedMapperConfig`, `JsonColumnsApplication` |
|
||||
| `src/test/java/.../A01..A11` | one test class per output file (A10 lives in `src/hypersistence-test`, active with `-Phypersistence`) |
|
||||
| `src/test/java/.../Db.java`, `SqlLog.java`, `Transcript.java`, `HqlRunner.java`, `Seed.java` | plain-Hibernate bootstrap, SQL capture, transcript writer, shared HQL case runner, shared sample rows |
|
||||
| `src/test/java/.../FormatMapperProbe.java` | `main()` run by `scripts/format-mapper-matrix.sh` under nine classpaths |
|
||||
| `scripts/` | `start-databases.sh`, `run-all.sh`, `versions.sh`, `format-mapper-matrix.sh`, `javap.sh`, `maven-facts.sh` |
|
||||
|
||||
## Output
|
||||
|
||||
| File | What it shows |
|
||||
|---|---|
|
||||
| `00-versions.txt` | JDK, resolved library versions, PostgreSQL and MySQL server versions |
|
||||
| `01-ddl-postgresql.txt` | the `CREATE TABLE` Hibernate emits for six JSON mappings (json vs jsonb), `json` vs `jsonb` storage differences |
|
||||
| `02-mapping-roundtrip.txt` | Map / List / record / POJO round trip: INSERT, stored JSON, Java types that come back, null vs empty |
|
||||
| `03-dirty-checking.txt` | in-place mutations detected or not, spurious UPDATEs (no `equals()`, `BigDecimal`), `@DynamicUpdate` |
|
||||
| `04-hql-json-functions-postgresql.txt` | every Hibernate 7.4 HQL JSON function run on PostgreSQL 18: generated SQL and result |
|
||||
| `05-embeddable-json-postgresql.txt` | `@Embeddable` stored as JSON, navigated in HQL without `json_value()` |
|
||||
| `06-native-jsonb-operators.txt` | `->>`, `@>`, `?`, `@?`, `@@` in native queries and what Hibernate's parameter parser does to them |
|
||||
| `07-indexing-explain.txt` | 50,000 rows, `EXPLAIN (ANALYZE)` before and after GIN, `jsonb_path_ops` and expression indexes |
|
||||
| `08-mysql.txt` | DDL, round trip, dirty checking, HQL functions, native functions and indexes on MySQL 8.0 |
|
||||
| `09-format-mapper-matrix.txt` | which `FormatMapper` Hibernate picks under nine classpath / setting combinations |
|
||||
| `09-format-mapper-inprocess.txt` | schema evolution failures, `@JsonIgnoreProperties`, a custom `JsonMapper`, `java.time` inside a Map |
|
||||
| `10-hypersistence-*.txt` | `@TypeDef` is gone; hypersistence-utils `JsonType` on Hibernate 7.4.14 with and without Jackson 2 |
|
||||
| `11-spring-boot.txt` | Spring Data repositories with native and HQL JSON queries; Boot's `JsonMapper` is not Hibernate's |
|
||||
| `12-javap-deepcopy.txt` | `javap` of the JSON snapshot copy (`deepCopy` = serialise then parse) and of `areEqual` (`Objects.equals`) |
|
||||
| `13-maven-facts.txt` | release dates and `maven-metadata.xml` facts read from Maven Central (versions callout, hypersistence-utils vs the old hibernate-types) |
|
||||
|
||||
## Not covered (not run)
|
||||
|
||||
Oracle, SQL Server, DB2, H2 and MariaDB JSON dialects, Hibernate's JSON mapping with `SqlTypes.JSON_ARRAY`, `@Embeddable` aggregates on MySQL beyond a single query,
|
||||
`jsonb_path_ops` plus `@?` path indexing, PostgreSQL `jsonb_ops` write amplification, concurrent-write load, `JsonB` (Yasson) as the format mapper, and Hibernate 8.
|
||||
|
||||
MIT — see [LICENSE](../LICENSE).
|
||||
@@ -0,0 +1,21 @@
|
||||
# java -version
|
||||
openjdk version "25.0.5" 2026-10-20 LTS
|
||||
OpenJDK Runtime Environment Temurin-25.0.5+8 (build 25.0.5+8-LTS)
|
||||
OpenJDK 64-Bit Server VM Temurin-25.0.5+8 (build 25.0.5+8-LTS, mixed mode, sharing)
|
||||
|
||||
# resolved libraries (mvn dependency:list, default profile)
|
||||
com.fasterxml.jackson.core:jackson-annotations:jar:2.21:compile -- module com.fasterxml.jackson.annotation
|
||||
com.mysql:mysql-connector-j:jar:9.7.0:compile -- module mysql.connector.j (auto)
|
||||
jakarta.persistence:jakarta.persistence-api:jar:3.2.0:compile -- module jakarta.persistence
|
||||
org.hibernate.orm:hibernate-core:jar:7.4.14.Final:compile -- module org.hibernate.orm.core [auto]
|
||||
org.postgresql:postgresql:jar:42.7.14:compile -- module org.postgresql.jdbc [auto]
|
||||
org.springframework.boot:spring-boot:jar:4.1.2:compile -- module spring.boot [auto]
|
||||
org.springframework.data:spring-data-jpa:jar:4.1.2:compile -- module spring.data.jpa [auto]
|
||||
tools.jackson.core:jackson-core:jar:3.1.7:compile -- module tools.jackson.core
|
||||
tools.jackson.core:jackson-databind:jar:3.1.7:compile -- module tools.jackson.databind
|
||||
|
||||
# PostgreSQL server
|
||||
PostgreSQL 18.6 on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0, 64-bit
|
||||
|
||||
# MySQL server
|
||||
8.0.46-0ubuntu0.24.04.4
|
||||
@@ -0,0 +1,36 @@
|
||||
# PostgreSQL 18.6 on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0, 64-bit
|
||||
# Hibernate 7.4.14.Final
|
||||
|
||||
== CREATE TABLE as Hibernate emits it (JPA script generation, nothing executed)
|
||||
create table t_coldef_json (id bigint generated by default as identity, doc json, primary key (id));
|
||||
create table t_coldef_jsonb (id bigint generated by default as identity, doc jsonb, primary key (id));
|
||||
create table t_default (id bigint generated by default as identity, doc jsonb, primary key (id));
|
||||
create table t_jdbctype_json (id bigint generated by default as identity, doc jsonb, primary key (id));
|
||||
create table t_jdbctype_jsonb (id bigint generated by default as identity, doc jsonb, primary key (id));
|
||||
create table t_list (id bigint generated by default as identity, items jsonb, primary key (id));
|
||||
|
||||
== information_schema after hbm2ddl=create (column, data_type)
|
||||
t_coldef_json.doc | json
|
||||
t_coldef_jsonb.doc | jsonb
|
||||
t_default.doc | jsonb
|
||||
t_jdbctype_json.doc | jsonb
|
||||
t_jdbctype_jsonb.doc | jsonb
|
||||
t_list.items | jsonb
|
||||
|
||||
== what is stored: pg_typeof(doc) and the text PostgreSQL returns
|
||||
t_default | jsonb | {"a": 1, "b": ["x", "y"]}
|
||||
t_coldef_json | json | {"a": 1, "b": ["x", "y"]}
|
||||
t_coldef_jsonb | jsonb | {"a": 1, "b": ["x", "y"]}
|
||||
t_jdbctype_json | jsonb | {"a": 1, "b": ["x", "y"]}
|
||||
t_jdbctype_jsonb | jsonb | {"a": 1, "b": ["x", "y"]}
|
||||
t_list | jsonb | ["p", "q"]
|
||||
|
||||
== reading each variant back through Hibernate
|
||||
t_default -> {a=1, b=[x, y]}
|
||||
t_coldef_json -> {a=1, b=[x, y]}
|
||||
t_jdbctype_json-> {a=1, b=[x, y]}
|
||||
|
||||
== json vs jsonb is observable: key order, duplicate keys and whitespace
|
||||
json | jsonb: {"z": 1, "a": 2, "a": 3} | {"a": 3, "z": 1}
|
||||
json = json: ERROR: operator does not exist: json = json
|
||||
jsonb = jsonb: t
|
||||
@@ -0,0 +1,28 @@
|
||||
|
||||
== persist(): the INSERT Hibernate sends (one bind per JSON column)
|
||||
sql> insert into product (attributes,dimensions,name,specs,tags) values (?,?,?,?,?)
|
||||
sql> update product set attributes=?,dimensions=?,name=?,specs=?,tags=? where id=?
|
||||
|
||||
== what PostgreSQL stored (jsonb text)
|
||||
attributes = {"color": "red", "price": 19.90, "ratio": 0.5, "stock": 12, "active": true, "nested": {"k": [1, 2, 3]}, "nothing": null, "bigNumber": 3000000000}
|
||||
tags = ["lighting", "office"]
|
||||
dimensions = {"depthMm": 180, "widthMm": 180, "heightMm": 420}
|
||||
specs = {"material": "steel", "weightKg": 1.8, "certifications": ["CE", "RoHS"]}
|
||||
|
||||
== loaded back in a new session: Java types of the Map values
|
||||
color -> String red
|
||||
price -> Double 19.9
|
||||
ratio -> Double 0.5
|
||||
stock -> Integer 12
|
||||
active -> Boolean true
|
||||
nested -> LinkedHashMap {k=[1, 2, 3]}
|
||||
nothing -> null null
|
||||
bigNumber -> Long 3000000000
|
||||
dimensions -> Dimensions[widthMm=180, heightMm=420, depthMm=180]
|
||||
specs -> steel, 1.8, [CE, RoHS]
|
||||
|
||||
== Java null vs JSON null vs empty
|
||||
null-attrs | attributes=SQL NULL | tags=SQL NULL | dimensions=SQL NULL
|
||||
empty-attrs | attributes={} | tags=[] | dimensions=SQL NULL
|
||||
loaded null-attrs : attributes=null tags=null
|
||||
loaded empty-attrs : attributes={} tags=[]
|
||||
@@ -0,0 +1,44 @@
|
||||
|
||||
== baseline: persist a Product whose Map holds only JSON-native Java types (String, Integer, Double, Map, List)
|
||||
sql> insert into product (attributes,dimensions,name,specs,tags) values (?,?,?,?,?)
|
||||
|
||||
== load, change nothing, commit
|
||||
UPDATE statements: 0
|
||||
|
||||
== in-place mutations: load, mutate the object graph, commit, count UPDATEs
|
||||
attributes.put("color", "blue") -> 1 UPDATE
|
||||
update product set attributes=?,dimensions=?,name=?,specs=?,tags=? where id=?
|
||||
attributes.remove("stock") -> 1 UPDATE
|
||||
update product set attributes=?,dimensions=?,name=?,specs=?,tags=? where id=?
|
||||
((Map) attributes.get("nested")).put("k2", 1) [nested map] -> 1 UPDATE
|
||||
update product set attributes=?,dimensions=?,name=?,specs=?,tags=? where id=?
|
||||
((List) nested.get("k")).add(4) [list inside map] -> 1 UPDATE
|
||||
update product set attributes=?,dimensions=?,name=?,specs=?,tags=? where id=?
|
||||
tags.add("c") -> 1 UPDATE
|
||||
update product set attributes=?,dimensions=?,name=?,specs=?,tags=? where id=?
|
||||
specs.setMaterial("brass") [POJO setter] -> 1 UPDATE
|
||||
update product set attributes=?,dimensions=?,name=?,specs=?,tags=? where id=?
|
||||
specs.getCertifications().add("UL") [list inside POJO] -> 1 UPDATE
|
||||
update product set attributes=?,dimensions=?,name=?,specs=?,tags=? where id=?
|
||||
setDimensions(new Dimensions(9,9,9)) [record replaced] -> 1 UPDATE
|
||||
update product set attributes=?,dimensions=?,name=?,specs=?,tags=? where id=?
|
||||
setDimensions(equal record) [no real change] -> 0 UPDATE
|
||||
|
||||
== the changes really reached the database
|
||||
attributes = {"color": "blue", "price": 19.9, "nested": {"k": [1, 2, 3, 4], "k2": 1}}
|
||||
tags = ["a", "b", "c"]
|
||||
specs = {"material": "brass", "weightKg": 1.8, "certifications": ["CE", "UL"]}
|
||||
|
||||
== spurious UPDATEs: nothing changed, Hibernate writes anyway
|
||||
persist() with attributes.price = new BigDecimal("19.90") -> 1 UPDATE right after the INSERT
|
||||
sql> insert into product (attributes,dimensions,name,specs,tags) values (?,?,?,?,?)
|
||||
sql> update product set attributes=?,dimensions=?,name=?,specs=?,tags=? where id=?
|
||||
stored price after that persist: 19.90
|
||||
then a plain load + commit of that row -> 0 UPDATE
|
||||
load + commit of a row whose POJO has NO equals(), nothing touched -> 1 UPDATE
|
||||
sql> select l1_0.id,l1_0.specs from loose l1_0 where l1_0.id=?
|
||||
sql> update loose set specs=? where id=?
|
||||
same for Specs, which HAS equals() (Product row, nothing touched) -> 0 UPDATE
|
||||
|
||||
== @DynamicUpdate: only the changed JSON column is written
|
||||
sql> update dyn set a=? where id=?
|
||||
@@ -0,0 +1,161 @@
|
||||
# PostgreSQL 18.6 on x86_64-pc-linux-gnu
|
||||
|
||||
== PART 1: default settings (hibernate.query.hql.json_functions_enabled not set)
|
||||
|
||||
== default: json_value
|
||||
hql> select p.name, json_value(p.attributes, '$.color') from Product p where p.id = 1
|
||||
FAILED IllegalArgumentException: Can't use function 'json_value', because tech preview JSON functions are not enabled. To enable, set the 'hibernate.query.hql.json_functions_enabled' setting to 'true'. [select p.name, json_value(p.attributes, '$.color') from Product p where p.id = 1]
|
||||
|
||||
== default: json_exists
|
||||
hql> select p.name from Product p where json_exists(p.attributes, '$.color')
|
||||
FAILED IllegalArgumentException: Can't use function 'json_exists', because tech preview JSON functions are not enabled. To enable, set the 'hibernate.query.hql.json_functions_enabled' setting to 'true'. [select p.name from Product p where json_exists(p.attributes, '$.color')]
|
||||
|
||||
== default: json_set (UPDATE)
|
||||
hql> update Product p set p.attributes = json_set(p.attributes, '$.color', 'green') where p.id = 1
|
||||
sql> update product p1_0 set attributes=jsonb_set(p1_0.attributes,array['color']::text[],to_jsonb('green'::text),true) where p1_0.id=1
|
||||
OK, 1 row(s) updated
|
||||
|
||||
== PART 2: hibernate.query.hql.json_functions_enabled=true
|
||||
|
||||
== json_value, string
|
||||
hql> select p.name, json_value(p.attributes, '$.color') from Product p order by p.id
|
||||
sql> select p1_0.name,json_value(p1_0.attributes,'$.color' returning jsonb)#>>'{}' from product p1_0 order by p1_0.id
|
||||
OK (Desk lamp, red); (Floor lamp, black); (Office chair, black); (Bookshelf, oak); (Monitor arm, silver)
|
||||
|
||||
== json_value, returning integer
|
||||
hql> select p.name, json_value(p.attributes, '$.stock' returning integer) from Product p order by p.id
|
||||
sql> select p1_0.name,json_value(p1_0.attributes,'$.stock' returning integer) from product p1_0 order by p1_0.id
|
||||
OK (Desk lamp, 12); (Floor lamp, 3); (Office chair, 0); (Bookshelf, 7); (Monitor arm, 25)
|
||||
|
||||
== json_value in where
|
||||
hql> select p.name from Product p where json_value(p.attributes, '$.color') = 'black' order by p.id
|
||||
sql> select p1_0.name from product p1_0 where json_value(p1_0.attributes,'$.color' returning jsonb)#>>'{}'='black' order by p1_0.id
|
||||
OK Floor lamp; Office chair
|
||||
|
||||
== json_value nested path + array index
|
||||
hql> select p.name, json_value(p.attributes, '$.nested.aisles[1]' returning integer) from Product p order by p.id
|
||||
sql> select p1_0.name,json_value(p1_0.attributes,'$.nested.aisles[1]' returning integer) from product p1_0 order by p1_0.id
|
||||
OK (Desk lamp, 7); (Floor lamp, 7); (Office chair, 7); (Bookshelf, 7); (Monitor arm, 7)
|
||||
|
||||
== json_value on a record column
|
||||
hql> select p.name, json_value(p.dimensions, '$.widthMm' returning integer) from Product p order by p.id
|
||||
sql> select p1_0.name,json_value(p1_0.dimensions,'$.widthMm' returning integer) from product p1_0 order by p1_0.id
|
||||
OK (Desk lamp, 180); (Floor lamp, 300); (Office chair, 600); (Bookshelf, 900); (Monitor arm, 150)
|
||||
|
||||
== json_value on a list column
|
||||
hql> select p.name, json_value(p.tags, '$[0]') from Product p order by p.id
|
||||
sql> select p1_0.name,json_value(p1_0.tags,'$[0]' returning jsonb)#>>'{}' from product p1_0 order by p1_0.id
|
||||
OK (Desk lamp, lighting); (Floor lamp, lighting); (Office chair, office); (Bookshelf, furniture); (Monitor arm, office)
|
||||
|
||||
== json_value, missing key
|
||||
hql> select p.name, json_value(p.attributes, '$.nope') from Product p where p.id = 1
|
||||
sql> select p1_0.name,json_value(p1_0.attributes,'$.nope' returning jsonb)#>>'{}' from product p1_0 where p1_0.id=1
|
||||
OK (Desk lamp, null)
|
||||
|
||||
== json_query (sub-document)
|
||||
hql> select p.name, json_query(p.attributes, '$.nested') from Product p where p.id = 1
|
||||
sql> select p1_0.name,json_query(p1_0.attributes,'$.nested') from product p1_0 where p1_0.id=1
|
||||
OK (Desk lamp, {"aisles": [3, 7], "warehouse": "PUNE"})
|
||||
|
||||
== json_query with wrapper
|
||||
hql> select p.name, json_query(p.attributes, '$.nested.aisles[*]' with wrapper) from Product p where p.id = 1
|
||||
sql> select p1_0.name,json_query(p1_0.attributes,'$.nested.aisles[*]' with wrapper) from product p1_0 where p1_0.id=1
|
||||
OK (Desk lamp, [3, 7])
|
||||
|
||||
== json_exists
|
||||
hql> select p.name from Product p where json_exists(p.attributes, '$.nested.warehouse') order by p.id
|
||||
sql> select p1_0.name from product p1_0 where json_exists(p1_0.attributes,'$.nested.warehouse') order by p1_0.id
|
||||
OK Desk lamp; Floor lamp; Office chair; Bookshelf; Monitor arm
|
||||
|
||||
== json_exists with filter
|
||||
hql> select p.name from Product p where json_exists(p.attributes, '$.stock ? (@ > 5)') order by p.id
|
||||
sql> select p1_0.name from product p1_0 where json_exists(p1_0.attributes,'$.stock ? (@ > 5)') order by p1_0.id
|
||||
OK Desk lamp; Bookshelf; Monitor arm
|
||||
|
||||
== json_object
|
||||
hql> select json_object('name', p.name, 'id', p.id) from Product p where p.id = 1
|
||||
sql> select json_object('name' value p1_0.name,'id' value p1_0.id) from product p1_0 where p1_0.id=1
|
||||
OK {"name" : "Desk lamp", "id" : 1}
|
||||
|
||||
== json_object, key/value keywords
|
||||
hql> select json_object(key 'name' value p.name) from Product p where p.id = 1
|
||||
sql> select json_object('name' value p1_0.name) from product p1_0 where p1_0.id=1
|
||||
OK {"name" : "Desk lamp"}
|
||||
|
||||
== json_array
|
||||
hql> select json_array(p.name, p.id, null) from Product p where p.id = 1
|
||||
sql> select json_array(p1_0.name,p1_0.id,null) from product p1_0 where p1_0.id=1
|
||||
OK ["Desk lamp", 1]
|
||||
|
||||
== json_arrayagg
|
||||
hql> select json_arrayagg(p.name) from Product p
|
||||
sql> select json_arrayagg(p1_0.name absent on null returning jsonb) from product p1_0
|
||||
OK ["Desk lamp", "Floor lamp", "Office chair", "Bookshelf", "Monitor arm"]
|
||||
|
||||
== json_objectagg
|
||||
hql> select json_objectagg(key p.name value p.id) from Product p
|
||||
sql> select json_objectagg(p1_0.name:p1_0.id absent on null returning jsonb) from product p1_0
|
||||
OK {"Bookshelf": 4, "Desk lamp": 1, "Floor lamp": 2, "Monitor arm": 5, "Office chair": 3}
|
||||
|
||||
== json_table over a list column
|
||||
hql> select p.name, t.tag from Product p join lateral json_table(p.tags, '$[*]' columns (tag String path '$')) t where p.id = 1
|
||||
sql> select p1_0.name,t1_0.tag from product p1_0 join lateral json_table(p1_0.tags,'$[*]' columns(tag text path '$')) t1_0 on true where p1_0.id=1
|
||||
OK (Desk lamp, lighting); (Desk lamp, office)
|
||||
|
||||
== json_table, tags counted
|
||||
hql> 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
|
||||
sql> select t1_0.tag,count(*) from product p1_0 join lateral json_table(p1_0.tags,'$[*]' columns(tag text path '$')) t1_0 on true group by t1_0.tag order by t1_0.tag
|
||||
OK (accessories, 1); (furniture, 2); (home, 2); (lighting, 2); (office, 3)
|
||||
|
||||
== json_set
|
||||
hql> update Product p set p.attributes = json_set(p.attributes, '$.color', 'green') where p.id = 1
|
||||
sql> update product p1_0 set attributes=jsonb_set(p1_0.attributes,array['color']::text[],to_jsonb('green'::text),true) where p1_0.id=1
|
||||
OK, 1 row(s) updated
|
||||
|
||||
== json_insert
|
||||
hql> update Product p set p.attributes = json_insert(p.attributes, '$.brand', 'Acme') where p.id = 1
|
||||
sql> update product p1_0 set attributes=(select case when (t.d)#>>t.p is not null then t.d else jsonb_insert(t.d,t.p,to_jsonb('Acme'::text),true) end from (values(p1_0.attributes,array['brand']::text[])) t(d,p)) where p1_0.id=1
|
||||
OK, 1 row(s) updated
|
||||
|
||||
== json_replace
|
||||
hql> update Product p set p.attributes = json_replace(p.attributes, '$.stock', 99) where p.id = 1
|
||||
sql> update product p1_0 set attributes=jsonb_set(p1_0.attributes,array['stock']::text[],to_jsonb(99),false) where p1_0.id=1
|
||||
OK, 1 row(s) updated
|
||||
|
||||
== json_remove
|
||||
hql> update Product p set p.attributes = json_remove(p.attributes, '$.price') where p.id = 1
|
||||
sql> update product p1_0 set attributes=p1_0.attributes#-array['price']::text[] where p1_0.id=1
|
||||
OK, 1 row(s) updated
|
||||
|
||||
== json_mergepatch
|
||||
hql> update Product p set p.attributes = json_mergepatch(p.attributes, '{"stock": 1, "brand": null}') where p.id = 2
|
||||
sql> update product p1_0 set attributes=(with recursive args(d0,d1) as(select p1_0.attributes,cast('{"stock": 1, "brand": null}' as jsonb)),val0(p,k,v) as (select '{}'::text[],s.k,t.d0->s.k from args t join lateral jsonb_object_keys(t.d0) s(k) on 1=1 union select v.p||v.k,s.k,v.v->s.k from val0 v join lateral jsonb_object_keys(v.v) s(k) on jsonb_typeof(v.v)='object'),val1(p,k,v) as (select '{}'::text[],s.k,t.d1->s.k from args t join lateral jsonb_object_keys(t.d1) s(k) on 1=1 union select v.p||v.k,s.k,v.v->s.k from val1 v join lateral jsonb_object_keys(v.v) s(k) on jsonb_typeof(v.v)='object'),res(v,p,l) as(select jsonb_object_agg(coalesce(v1.k,v0.k),coalesce(v1.v,v0.v)),coalesce(v1.p,v0.p),cardinality(coalesce(v1.p,v0.p)) from val0 v0 full join val1 v1 on v0.p=v1.p and v0.k=v1.k where cardinality(coalesce(v1.p,v0.p))=(select cardinality(v.p) from val0 v union select cardinality(v.p) from val1 v order by 1 desc limit 1) and jsonb_typeof(coalesce(v1.v)) is distinct from 'null' group by coalesce(v1.p,v0.p),cardinality(coalesce(v1.p,v0.p)) union all select jsonb_object_agg(coalesce(v1.k,v0.k),coalesce(case when coalesce(v1.k,v0.k)=r.p[cardinality(r.p)] then r.v end,v1.v,v0.v)) filter (where coalesce(case when coalesce(v1.k,v0.k)=r.p[cardinality(r.p)] then r.v end,v1.v,v0.v) is not null),coalesce(v1.p,v0.p),r.l-1 from val0 v0 full join val1 v1 on v0.p=v1.p and v0.k=v1.k join (select * from res r order by r.l fetch first 1 rows with ties) r on cardinality(coalesce(v1.p,v0.p))=r.l-1 and jsonb_typeof(coalesce(v1.v)) is distinct from 'null' and r.l<>0 group by coalesce(v1.p,v0.p),r.l-1) select r.v from res r where r.l=0) where p1_0.id=2
|
||||
OK, 1 row(s) updated
|
||||
|
||||
== json_array_append, root path $
|
||||
hql> update Product p set p.tags = json_array_append(p.tags, '$', 'sale') where p.id = 1
|
||||
sql> update product p1_0 set tags=(select jsonb_set_lax(t.d,t.p,(t.d)#>t.p||to_jsonb('sale'::text),false,'return_target') from (values(p1_0.tags,array]::text[])) t(d,p)) where p1_0.id=1
|
||||
FAILED SQLGrammarException: ERROR: syntax error at or near "]"
|
||||
|
||||
== json_array_append, nested path
|
||||
hql> update Product p set p.attributes = json_array_append(p.attributes, '$.nested.aisles', 11) where p.id = 1
|
||||
sql> update product p1_0 set attributes=(select jsonb_set_lax(t.d,t.p,(t.d)#>t.p||to_jsonb(11),false,'return_target') from (values(p1_0.attributes,array['nested','aisles']::text[])) t(d,p)) where p1_0.id=1
|
||||
OK, 1 row(s) updated
|
||||
|
||||
== json_array_insert
|
||||
hql> update Product p set p.tags = json_array_insert(p.tags, '$[0]', 'first') where p.id = 2
|
||||
sql> update product p1_0 set tags=jsonb_insert(p1_0.tags,array['0']::text[],to_jsonb('first'::text)) where p1_0.id=2
|
||||
OK, 1 row(s) updated
|
||||
|
||||
== attribute navigation p.attributes.color
|
||||
hql> select p.name from Product p where p.attributes.color = 'black'
|
||||
FAILED IllegalArgumentException: Could not interpret attribute 'color' of basic-valued path 'com.ankurm.jsoncolumns.model.Product(p).attributes'
|
||||
|
||||
== function('jsonb_extract_path_text')
|
||||
hql> select function('jsonb_extract_path_text', p.attributes, 'color') from Product p where p.id = 3
|
||||
sql> select jsonb_extract_path_text(p1_0.attributes,'color') from product p1_0 where p1_0.id=3
|
||||
OK black
|
||||
|
||||
== rows after the UPDATE experiments (raw jsonb)
|
||||
1 {"brand": "Acme", "color": "green", "stock": 99, "nested": {"aisles": [3, 7, 11], "warehouse": "PUNE"}} ["lighting", "office"]
|
||||
2 {"color": "black", "price": 89.5, "stock": 1, "nested": {"aisles": [3, 7], "warehouse": "PUNE"}} ["first", "lighting", "home"]
|
||||
@@ -0,0 +1,28 @@
|
||||
# PostgreSQL 18.6 on x86_64-pc-linux-gnu
|
||||
|
||||
== DDL
|
||||
id bigint
|
||||
name character varying
|
||||
address jsonb
|
||||
|
||||
== stored
|
||||
Asha {"zip": "411001", "city": "Pune", "floor": 3}
|
||||
Ravi {"zip": "400001", "city": "Mumbai", "floor": 12}
|
||||
Meera {"zip": "411045", "city": "Pune", "floor": 1}
|
||||
|
||||
== HQL: where c.address.city = 'Pune'
|
||||
sql> select c1_0.name from customer c1_0 where cast(c1_0.address->>'city' as varchar)='Pune' order by c1_0.id
|
||||
result: [Asha, Meera]
|
||||
|
||||
== HQL: select and order by an int attribute inside the JSON
|
||||
sql> select cast(c1_0.address->>'floor' as integer) from customer c1_0 where cast(c1_0.address->>'floor' as integer)>2 order by cast(c1_0.address->>'floor' as integer)
|
||||
result: [3, 12]
|
||||
|
||||
== HQL: update c.address.floor = 99
|
||||
sql> update customer c1_0 set address=coalesce(c1_0.address,'{}')||jsonb_build_object('floor',to_jsonb(99)) where c1_0.name='Ravi'
|
||||
rows updated: 1; stored: {"zip": "400001", "city": "Mumbai", "floor": 99}
|
||||
|
||||
== HQL: group by c.address.city
|
||||
sql> select cast(c1_0.address->>'city' as varchar),count(*) from customer c1_0 group by cast(c1_0.address->>'city' as varchar) order by cast(c1_0.address->>'city' as varchar)
|
||||
row: Mumbai 1
|
||||
row: Pune 2
|
||||
@@ -0,0 +1,94 @@
|
||||
# PostgreSQL 18.6 on x86_64-pc-linux-gnu
|
||||
|
||||
== ->> text extraction
|
||||
sql> select name from product where attributes->>'color' = 'black' order by id
|
||||
OK Floor lamp; Office chair
|
||||
|
||||
== ->> with a named parameter
|
||||
sql> select name from product where attributes->>'color' = :c order by id -- {c=black}
|
||||
OK Floor lamp; Office chair
|
||||
|
||||
== -> then #>> path
|
||||
sql> select name from product where attributes #>> '{nested,warehouse}' = 'PUNE' and id = 1
|
||||
OK Desk lamp
|
||||
|
||||
== ->> cast to int for comparison
|
||||
sql> select name from product where (attributes->>'stock')::int > 5 order by id
|
||||
OK Desk lamp; Bookshelf; Monitor arm
|
||||
|
||||
== @> containment, literal
|
||||
sql> select name from product where tags @> '["office"]' order by id
|
||||
OK Desk lamp; Office chair; Monitor arm
|
||||
|
||||
== @> containment, parameter with cast(:p as jsonb)
|
||||
sql> select name from product where attributes @> cast(:p as jsonb) order by id -- {p={"color":"black"}}
|
||||
OK Floor lamp; Office chair
|
||||
|
||||
== @> containment, parameter with :p::jsonb
|
||||
sql> select name from product where attributes @> :p::jsonb order by id -- {p={"color":"black"}}
|
||||
FAILED IllegalArgumentException: No parameter named ':p' in query with named parameters [p::jsonb]
|
||||
|
||||
== ? key-exists, bare question mark
|
||||
sql> select name from product where attributes ? 'color' order by id
|
||||
FAILED QueryParameterException: No argument for ordinal parameter '?1'
|
||||
|
||||
== ? key-exists, backslash-escaped
|
||||
sql> select name from product where attributes \? 'color' order by id
|
||||
FAILED DataException: No value specified for parameter 1.
|
||||
|
||||
== ? key-exists, each ? escaped: \?\?
|
||||
sql> select name from product where attributes \?\? 'color' order by id
|
||||
OK Desk lamp; Floor lamp; Office chair; Bookshelf; Monitor arm
|
||||
|
||||
== ? key-exists, doubled ??
|
||||
sql> select name from product where attributes ?? 'color' order by id
|
||||
FAILED QueryParameterException: No argument for ordinal parameter '?1'
|
||||
|
||||
== jsonb_exists() function instead of ?
|
||||
sql> select name from product where jsonb_exists(attributes, 'color') order by id
|
||||
OK Desk lamp; Floor lamp; Office chair; Bookshelf; Monitor arm
|
||||
|
||||
== ?| any-key, doubled ??|
|
||||
sql> select name from product where tags ??| array['home','accessories'] order by id
|
||||
FAILED QueryParameterException: No argument for ordinal parameter '?1'
|
||||
|
||||
== ?| any-key, escaped: \?\?|
|
||||
sql> select name from product where tags \?\?| array['home','accessories'] order by id
|
||||
OK Floor lamp; Bookshelf; Monitor arm
|
||||
|
||||
== jsonb_exists_any() function instead of ?|
|
||||
sql> select name from product where jsonb_exists_any(tags, array['home','accessories']) order by id
|
||||
OK Floor lamp; Bookshelf; Monitor arm
|
||||
|
||||
== jsonb_path_exists()
|
||||
sql> select name from product where jsonb_path_exists(attributes, '$.stock ? (@ > 5)') order by id
|
||||
OK Desk lamp; Bookshelf; Monitor arm
|
||||
|
||||
== @? path operator
|
||||
sql> select name from product where attributes @? '$.stock ? (@ > 5)' order by id
|
||||
FAILED QueryParameterException: No argument for ordinal parameter '?1'
|
||||
|
||||
== @? path operator, escaped: @\?\?
|
||||
sql> select name from product where attributes @\?\? '$.stock ? (@ > 5)' order by id
|
||||
OK Desk lamp; Bookshelf; Monitor arm
|
||||
|
||||
== @@ path predicate
|
||||
sql> select name from product where attributes @@ '$.stock > 5' order by id
|
||||
OK Desk lamp; Bookshelf; Monitor arm
|
||||
|
||||
== jsonb_array_elements_text in a lateral join
|
||||
sql> select t.tag, count(*) from product p, jsonb_array_elements_text(p.tags) t(tag) group by t.tag order by t.tag
|
||||
OK (accessories, 1); (furniture, 2); (home, 2); (lighting, 2); (office, 3)
|
||||
|
||||
== jsonb_set in an UPDATE (native)
|
||||
sql> update product set attributes = jsonb_set(attributes, '{color}', to_jsonb(cast(:v as text))) where id = 3 -- {v=white}
|
||||
OK, 1 row(s) updated
|
||||
|
||||
== What does a native SELECT of a jsonb column hand back to Java?
|
||||
createNativeQuery(sql).getSingleResult() -> java.lang.String
|
||||
createNativeQuery(sql, Map.class) -> java.util.HashMap
|
||||
createNativeQuery(sql, Product.class) -> attributes.color = red
|
||||
|
||||
== HQL equality on a jsonb column with a Java value as parameter
|
||||
where p.tags = :t with List.of("furniture","home") -> [Bookshelf]
|
||||
where p.tags = :t with List.of("home","furniture") -> [] (jsonb arrays are ordered)
|
||||
@@ -0,0 +1,207 @@
|
||||
# PostgreSQL 18.6 on x86_64-pc-linux-gnu
|
||||
# table created by Hibernate from the Event entity; rows from generate_series; max_parallel_workers_per_gather=0 for stable plans
|
||||
# Execution Time values are from this machine and drift between runs; the plan shapes are what the tests assert.
|
||||
|
||||
== data
|
||||
rows: 50000
|
||||
sample: {"tags": ["standard"], "type": "purchase", "amount": 4.2000000000000000, "device": {"os": "linux", "version": 8}, "userId": 42}
|
||||
table size (heap): 10000 kB
|
||||
|
||||
== 0 no index | Q1 containment @> {userId:42,type:purchase}
|
||||
sql> select count(*) from event where payload @> '{"userId":42,"type":"purchase"}'
|
||||
Aggregate (actual time=10.129..10.130 rows=1.00 loops=1)
|
||||
-> Seq Scan on event (actual time=0.027..10.113 rows=10.00 loops=1)
|
||||
Filter: (payload @> '{"type": "purchase", "userId": 42}'::jsonb)
|
||||
Rows Removed by Filter: 49990
|
||||
Planning Time: 0.362 ms
|
||||
Execution Time: 10.185 ms
|
||||
|
||||
== 0 no index | Q2 ->> equality userId = '42'
|
||||
sql> select count(*) from event where payload->>'userId' = '42'
|
||||
Aggregate (actual time=24.353..24.355 rows=1.00 loops=1)
|
||||
-> Seq Scan on event (actual time=0.037..24.326 rows=10.00 loops=1)
|
||||
Filter: ((payload ->> 'userId'::text) = '42'::text)
|
||||
Rows Removed by Filter: 49990
|
||||
Planning Time: 0.414 ms
|
||||
Execution Time: 24.412 ms
|
||||
|
||||
== 0 no index | Q3 key exists ? 'coupon'
|
||||
sql> select count(*) from event where payload ? 'coupon'
|
||||
Aggregate (actual time=15.209..15.211 rows=1.00 loops=1)
|
||||
-> Seq Scan on event (actual time=0.064..15.183 rows=100.00 loops=1)
|
||||
Filter: (payload ? 'coupon'::text)
|
||||
Rows Removed by Filter: 49900
|
||||
Planning Time: 0.261 ms
|
||||
Execution Time: 15.256 ms
|
||||
|
||||
== 0 no index | Q4 containment @> {tags:[vip]}
|
||||
sql> select count(*) from event where payload @> '{"tags":["vip"]}'
|
||||
Aggregate (actual time=20.045..20.046 rows=1.00 loops=1)
|
||||
-> Seq Scan on event (actual time=0.023..19.969 rows=1000.00 loops=1)
|
||||
Filter: (payload @> '{"tags": ["vip"]}'::jsonb)
|
||||
Rows Removed by Filter: 49000
|
||||
Planning Time: 0.314 ms
|
||||
Execution Time: 20.091 ms
|
||||
|
||||
== 1 GIN (payload) | index size
|
||||
idx_event_gin: 1840 kB
|
||||
|
||||
== 1 GIN (payload) | Q1 containment @> {userId:42,type:purchase}
|
||||
sql> select count(*) from event where payload @> '{"userId":42,"type":"purchase"}'
|
||||
Aggregate (actual time=0.626..0.627 rows=1.00 loops=1)
|
||||
-> Bitmap Heap Scan on event (actual time=0.572..0.622 rows=10.00 loops=1)
|
||||
Recheck Cond: (payload @> '{"type": "purchase", "userId": 42}'::jsonb)
|
||||
Heap Blocks: exact=10
|
||||
-> Bitmap Index Scan on idx_event_gin (actual time=0.561..0.561 rows=10.00 loops=1)
|
||||
Index Cond: (payload @> '{"type": "purchase", "userId": 42}'::jsonb)
|
||||
Index Searches: 1
|
||||
Planning Time: 0.413 ms
|
||||
Execution Time: 0.676 ms
|
||||
|
||||
== 1 GIN (payload) | Q2 ->> equality userId = '42'
|
||||
sql> select count(*) from event where payload->>'userId' = '42'
|
||||
Aggregate (actual time=27.085..27.088 rows=1.00 loops=1)
|
||||
-> Seq Scan on event (actual time=0.035..27.059 rows=10.00 loops=1)
|
||||
Filter: ((payload ->> 'userId'::text) = '42'::text)
|
||||
Rows Removed by Filter: 49990
|
||||
Planning Time: 0.432 ms
|
||||
Execution Time: 27.135 ms
|
||||
|
||||
== 1 GIN (payload) | Q3 key exists ? 'coupon'
|
||||
sql> select count(*) from event where payload ? 'coupon'
|
||||
Aggregate (actual time=0.413..0.414 rows=1.00 loops=1)
|
||||
-> Bitmap Heap Scan on event (actual time=0.054..0.404 rows=100.00 loops=1)
|
||||
Recheck Cond: (payload ? 'coupon'::text)
|
||||
Heap Blocks: exact=100
|
||||
-> Bitmap Index Scan on idx_event_gin (actual time=0.030..0.030 rows=100.00 loops=1)
|
||||
Index Cond: (payload ? 'coupon'::text)
|
||||
Index Searches: 1
|
||||
Planning Time: 0.426 ms
|
||||
Execution Time: 0.471 ms
|
||||
|
||||
== 1 GIN (payload) | Q4 containment @> {tags:[vip]}
|
||||
sql> select count(*) from event where payload @> '{"tags":["vip"]}'
|
||||
Aggregate (actual time=2.906..2.909 rows=1.00 loops=1)
|
||||
-> Bitmap Heap Scan on event (actual time=1.040..2.846 rows=1000.00 loops=1)
|
||||
Recheck Cond: (payload @> '{"tags": ["vip"]}'::jsonb)
|
||||
Heap Blocks: exact=1000
|
||||
-> Bitmap Index Scan on idx_event_gin (actual time=0.922..0.923 rows=1000.00 loops=1)
|
||||
Index Cond: (payload @> '{"tags": ["vip"]}'::jsonb)
|
||||
Index Searches: 1
|
||||
Planning Time: 0.447 ms
|
||||
Execution Time: 2.963 ms
|
||||
|
||||
== 2 GIN (payload jsonb_path_ops) | index size
|
||||
idx_event_gin_path: 1400 kB
|
||||
|
||||
== 2 GIN (payload jsonb_path_ops) | Q1 containment @> {userId:42,type:purchase}
|
||||
sql> select count(*) from event where payload @> '{"userId":42,"type":"purchase"}'
|
||||
Aggregate (actual time=0.124..0.125 rows=1.00 loops=1)
|
||||
-> Bitmap Heap Scan on event (actual time=0.072..0.120 rows=10.00 loops=1)
|
||||
Recheck Cond: (payload @> '{"type": "purchase", "userId": 42}'::jsonb)
|
||||
Heap Blocks: exact=10
|
||||
-> Bitmap Index Scan on idx_event_gin_path (actual time=0.059..0.059 rows=10.00 loops=1)
|
||||
Index Cond: (payload @> '{"type": "purchase", "userId": 42}'::jsonb)
|
||||
Index Searches: 1
|
||||
Planning Time: 0.487 ms
|
||||
Execution Time: 0.177 ms
|
||||
|
||||
== 2 GIN (payload jsonb_path_ops) | Q3 key exists ? 'coupon'
|
||||
sql> select count(*) from event where payload ? 'coupon'
|
||||
Aggregate (actual time=7.851..7.853 rows=1.00 loops=1)
|
||||
-> Seq Scan on event (actual time=0.076..7.829 rows=100.00 loops=1)
|
||||
Filter: (payload ? 'coupon'::text)
|
||||
Rows Removed by Filter: 49900
|
||||
Planning Time: 0.479 ms
|
||||
Execution Time: 7.893 ms
|
||||
|
||||
== 2 GIN (payload jsonb_path_ops) | Q4 containment @> {tags:[vip]}
|
||||
sql> select count(*) from event where payload @> '{"tags":["vip"]}'
|
||||
Aggregate (actual time=2.785..2.787 rows=1.00 loops=1)
|
||||
-> Bitmap Heap Scan on event (actual time=0.741..2.724 rows=1000.00 loops=1)
|
||||
Recheck Cond: (payload @> '{"tags": ["vip"]}'::jsonb)
|
||||
Heap Blocks: exact=1000
|
||||
-> Bitmap Index Scan on idx_event_gin_path (actual time=0.616..0.616 rows=1000.00 loops=1)
|
||||
Index Cond: (payload @> '{"tags": ["vip"]}'::jsonb)
|
||||
Index Searches: 1
|
||||
Planning Time: 0.698 ms
|
||||
Execution Time: 2.859 ms
|
||||
|
||||
== 3 expression b-tree | index sizes
|
||||
idx_event_user_text: 480 kB
|
||||
idx_event_user_int: 480 kB
|
||||
|
||||
== 3 expression b-tree | Q2 ->> equality userId = '42'
|
||||
sql> select count(*) from event where payload->>'userId' = '42'
|
||||
Aggregate (actual time=0.090..0.091 rows=1.00 loops=1)
|
||||
-> Bitmap Heap Scan on event (actual time=0.040..0.084 rows=10.00 loops=1)
|
||||
Recheck Cond: ((payload ->> 'userId'::text) = '42'::text)
|
||||
Heap Blocks: exact=10
|
||||
-> Bitmap Index Scan on idx_event_user_text (actual time=0.029..0.029 rows=10.00 loops=1)
|
||||
Index Cond: ((payload ->> 'userId'::text) = '42'::text)
|
||||
Index Searches: 1
|
||||
Planning Time: 0.540 ms
|
||||
Execution Time: 0.134 ms
|
||||
|
||||
== 3 expression b-tree | Q2b (payload->>'userId')::int = 42
|
||||
sql> select count(*) from event where (payload->>'userId')::int = 42
|
||||
Aggregate (actual time=0.095..0.096 rows=1.00 loops=1)
|
||||
-> Bitmap Heap Scan on event (actual time=0.049..0.088 rows=10.00 loops=1)
|
||||
Recheck Cond: (((payload ->> 'userId'::text))::integer = 42)
|
||||
Heap Blocks: exact=10
|
||||
-> Bitmap Index Scan on idx_event_user_int (actual time=0.039..0.039 rows=10.00 loops=1)
|
||||
Index Cond: (((payload ->> 'userId'::text))::integer = 42)
|
||||
Index Searches: 1
|
||||
Planning Time: 0.392 ms
|
||||
Execution Time: 0.133 ms
|
||||
|
||||
== 4 does the SQL that Hibernate generates use these indexes?
|
||||
results: json_value=10, embeddable=10, function=10
|
||||
|
||||
== 4 | HQL json_value(e.payload, '$.userId') = '42'
|
||||
sql> select count(*) from event e1_0 where json_value(e1_0.payload,'$.userId' returning jsonb)#>>'{}'='42'
|
||||
Aggregate (actual time=43.806..43.809 rows=1.00 loops=1)
|
||||
-> Seq Scan on event e1_0 (actual time=0.060..43.792 rows=10.00 loops=1)
|
||||
Filter: ((JSON_VALUE(payload, '$."userId"' RETURNING jsonb) #>> '{}'::text[]) = '42'::text)
|
||||
Rows Removed by Filter: 49990
|
||||
Planning Time: 0.400 ms
|
||||
Execution Time: 43.848 ms
|
||||
|
||||
== 4 | HQL e.payload.userId = 42 (typed @Embeddable)
|
||||
sql> select count(*) from event te1_0 where cast(te1_0.payload->>'userId' as integer)=42
|
||||
Aggregate (actual time=0.078..0.078 rows=1.00 loops=1)
|
||||
-> Bitmap Heap Scan on event te1_0 (actual time=0.027..0.071 rows=10.00 loops=1)
|
||||
Recheck Cond: (((payload ->> 'userId'::text))::integer = 42)
|
||||
Heap Blocks: exact=10
|
||||
-> Bitmap Index Scan on idx_event_user_int (actual time=0.015..0.015 rows=10.00 loops=1)
|
||||
Index Cond: (((payload ->> 'userId'::text))::integer = 42)
|
||||
Index Searches: 1
|
||||
Planning Time: 0.517 ms
|
||||
Execution Time: 0.122 ms
|
||||
|
||||
== 4 | HQL function('jsonb_extract_path_text', ...)
|
||||
sql> select count(*) from event e1_0 where jsonb_extract_path_text(e1_0.payload,'userId')='42'
|
||||
Aggregate (actual time=12.766..12.767 rows=1.00 loops=1)
|
||||
-> Seq Scan on event e1_0 (actual time=0.028..12.753 rows=10.00 loops=1)
|
||||
Filter: (jsonb_extract_path_text(payload, VARIADIC '{userId}'::text[]) = '42'::text)
|
||||
Rows Removed by Filter: 49990
|
||||
Planning Time: 0.451 ms
|
||||
Execution Time: 12.808 ms
|
||||
|
||||
== summary of Execution Time (ms), this machine, indicative only
|
||||
0 no index | Q1 containment @> {userId:42,type:purchase} 10.185
|
||||
0 no index | Q2 ->> equality userId = '42' 24.412
|
||||
0 no index | Q3 key exists ? 'coupon' 15.256
|
||||
0 no index | Q4 containment @> {tags:[vip]} 20.091
|
||||
1 GIN (payload) | Q1 containment @> {userId:42,type:purchase} 0.676
|
||||
1 GIN (payload) | Q2 ->> equality userId = '42' 27.135
|
||||
1 GIN (payload) | Q3 key exists ? 'coupon' 0.471
|
||||
1 GIN (payload) | Q4 containment @> {tags:[vip]} 2.963
|
||||
2 GIN (payload jsonb_path_ops) | Q1 containment @> {userId:42,type:purchase} 0.177
|
||||
2 GIN (payload jsonb_path_ops) | Q3 key exists ? 'coupon' 7.893
|
||||
2 GIN (payload jsonb_path_ops) | Q4 containment @> {tags:[vip]} 2.859
|
||||
3 expression b-tree | Q2 ->> equality userId = '42' 0.134
|
||||
3 expression b-tree | Q2b (payload->>'userId')::int = 42 0.133
|
||||
4 | HQL json_value(e.payload, '$.userId') = '42' 43.848
|
||||
4 | HQL e.payload.userId = 42 (typed @Embeddable) 0.122
|
||||
4 | HQL function('jsonb_extract_path_text', ...) 12.808
|
||||
@@ -0,0 +1,186 @@
|
||||
# MySQL 8.0.46-0ubuntu0.24.04.4
|
||||
# Hibernate 7.4.14.Final
|
||||
|
||||
== CREATE TABLE as Hibernate emits it for MySQL
|
||||
create table product (id bigint not null auto_increment, name varchar(255), attributes json, dimensions json, specs json, tags json, primary key (id)) engine=InnoDB;
|
||||
|
||||
== column types in information_schema
|
||||
attributes json
|
||||
dimensions json
|
||||
specs json
|
||||
tags json
|
||||
|
||||
== what MySQL stored (it normalises: keys sorted by length, then alphabetically)
|
||||
attributes = {"color": "red", "price": 19.9, "stock": 12, "nested": {"aisles": [3, 7], "warehouse": "PUNE"}}
|
||||
dimensions = {"depthMm": 180, "widthMm": 180, "heightMm": 360}
|
||||
|
||||
== dirty checking on MySQL
|
||||
load, change nothing: 0 UPDATE; attributes.put("color","teal"): 1 UPDATE
|
||||
stored color: teal
|
||||
|
||||
== HQL JSON functions on MySQL (same statements as 04-hql-json-functions-postgresql.txt)
|
||||
|
||||
== json_value, string
|
||||
hql> select p.name, json_value(p.attributes, '$.color') from Product p order by p.id
|
||||
sql> select p1_0.name,json_unquote(nullif(json_extract(p1_0.attributes,'$.color'),cast('null' as json))) from product p1_0 order by p1_0.id
|
||||
OK (Desk lamp, teal); (Floor lamp, black); (Office chair, black); (Bookshelf, oak); (Monitor arm, silver)
|
||||
|
||||
== json_value, returning integer
|
||||
hql> select p.name, json_value(p.attributes, '$.stock' returning integer) from Product p order by p.id
|
||||
sql> select p1_0.name,cast(json_unquote(nullif(json_extract(p1_0.attributes,'$.stock'),cast('null' as json))) as signed) from product p1_0 order by p1_0.id
|
||||
OK (Desk lamp, 12); (Floor lamp, 3); (Office chair, 0); (Bookshelf, 7); (Monitor arm, 25)
|
||||
|
||||
== json_value in where
|
||||
hql> select p.name from Product p where json_value(p.attributes, '$.color') = 'black' order by p.id
|
||||
sql> select p1_0.name from product p1_0 where json_unquote(nullif(json_extract(p1_0.attributes,'$.color'),cast('null' as json)))='black' order by p1_0.id
|
||||
OK Floor lamp; Office chair
|
||||
|
||||
== json_value nested path + array index
|
||||
hql> select p.name, json_value(p.attributes, '$.nested.aisles[1]' returning integer) from Product p order by p.id
|
||||
sql> select p1_0.name,cast(json_unquote(nullif(json_extract(p1_0.attributes,'$.nested.aisles[1]'),cast('null' as json))) as signed) from product p1_0 order by p1_0.id
|
||||
OK (Desk lamp, 7); (Floor lamp, 7); (Office chair, 7); (Bookshelf, 7); (Monitor arm, 7)
|
||||
|
||||
== json_value on a record column
|
||||
hql> select p.name, json_value(p.dimensions, '$.widthMm' returning integer) from Product p order by p.id
|
||||
sql> select p1_0.name,cast(json_unquote(nullif(json_extract(p1_0.dimensions,'$.widthMm'),cast('null' as json))) as signed) from product p1_0 order by p1_0.id
|
||||
OK (Desk lamp, 180); (Floor lamp, 300); (Office chair, 600); (Bookshelf, 900); (Monitor arm, 150)
|
||||
|
||||
== json_value on a list column
|
||||
hql> select p.name, json_value(p.tags, '$[0]') from Product p order by p.id
|
||||
sql> select p1_0.name,json_unquote(nullif(json_extract(p1_0.tags,'$[0]'),cast('null' as json))) from product p1_0 order by p1_0.id
|
||||
OK (Desk lamp, lighting); (Floor lamp, lighting); (Office chair, office); (Bookshelf, furniture); (Monitor arm, office)
|
||||
|
||||
== json_value, missing key
|
||||
hql> select p.name, json_value(p.attributes, '$.nope') from Product p where p.id = 1
|
||||
sql> select p1_0.name,json_unquote(nullif(json_extract(p1_0.attributes,'$.nope'),cast('null' as json))) from product p1_0 where p1_0.id=1
|
||||
OK (Desk lamp, null)
|
||||
|
||||
== json_query (sub-document)
|
||||
hql> select p.name, json_query(p.attributes, '$.nested') from Product p where p.id = 1
|
||||
sql> select p1_0.name,nullif(json_extract(p1_0.attributes,'$.nested'),cast('null' as json)) from product p1_0 where p1_0.id=1
|
||||
OK (Desk lamp, {"aisles": [3, 7], "warehouse": "PUNE"})
|
||||
|
||||
== json_query with wrapper
|
||||
hql> select p.name, json_query(p.attributes, '$.nested.aisles[*]' with wrapper) from Product p where p.id = 1
|
||||
sql> select p1_0.name,nullif(json_extract(p1_0.attributes,'$.nested.aisles[*]'),cast('null' as json)) from product p1_0 where p1_0.id=1
|
||||
OK (Desk lamp, [3, 7])
|
||||
|
||||
== json_exists
|
||||
hql> select p.name from Product p where json_exists(p.attributes, '$.nested.warehouse') order by p.id
|
||||
sql> select p1_0.name from product p1_0 where json_contains_path(p1_0.attributes,'one','$.nested.warehouse') order by p1_0.id
|
||||
OK Desk lamp; Floor lamp; Office chair; Bookshelf; Monitor arm
|
||||
|
||||
== json_exists with filter
|
||||
hql> select p.name from Product p where json_exists(p.attributes, '$.stock ? (@ > 5)') order by p.id
|
||||
sql> select p1_0.name from product p1_0 where json_contains_path(p1_0.attributes,'one','$.stock ? (@ > 5)') order by p1_0.id
|
||||
FAILED SQLGrammarException: Invalid JSON path expression. The error is around character position 8.
|
||||
|
||||
== json_object
|
||||
hql> select json_object('name', p.name, 'id', p.id) from Product p where p.id = 1
|
||||
sql> select json_object('name',p1_0.name,'id',p1_0.id) from product p1_0 where p1_0.id=1
|
||||
OK {"id": 1, "name": "Desk lamp"}
|
||||
|
||||
== json_object, key/value keywords
|
||||
hql> select json_object(key 'name' value p.name) from Product p where p.id = 1
|
||||
sql> select json_object('name',p1_0.name) from product p1_0 where p1_0.id=1
|
||||
OK {"name": "Desk lamp"}
|
||||
|
||||
== json_array
|
||||
hql> select json_array(p.name, p.id, null) from Product p where p.id = 1
|
||||
sql> select json_array(p1_0.name,p1_0.id,null) from product p1_0 where p1_0.id=1
|
||||
OK ["Desk lamp", 1, null]
|
||||
|
||||
== json_arrayagg
|
||||
hql> select json_arrayagg(p.name) from Product p
|
||||
sql> select cast(concat('[',group_concat(nullif(json_extract(json_array(p1_0.name),'$[0]'),cast('null' as json)) separator ','),']') as json) from product p1_0
|
||||
OK ["Desk lamp", "Floor lamp", "Office chair", "Bookshelf", "Monitor arm"]
|
||||
|
||||
== json_objectagg
|
||||
hql> select json_objectagg(key p.name value p.id) from Product p
|
||||
sql> select concat('{',group_concat(concat(json_quote(p1_0.name),':',nullif(json_extract(json_array(p1_0.id),'$[0]'),cast('null' as json))) separator ','),'}') from product p1_0
|
||||
OK {"Desk lamp":1,"Floor lamp":2,"Office chair":3,"Bookshelf":4,"Monitor arm":5}
|
||||
|
||||
== json_table over a list column
|
||||
hql> select p.name, t.tag from Product p join lateral json_table(p.tags, '$[*]' columns (tag String path '$')) t where p.id = 1
|
||||
sql> select p1_0.name,t1_0.tag from product p1_0 join json_table(p1_0.tags,'$[*]' columns(tag longtext path '$')) t1_0 on true where p1_0.id=1
|
||||
OK (Desk lamp, lighting); (Desk lamp, office)
|
||||
|
||||
== json_table, tags counted
|
||||
hql> 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
|
||||
sql> select t1_0.tag,count(*) from product p1_0 join json_table(p1_0.tags,'$[*]' columns(tag longtext path '$')) t1_0 on true group by t1_0.tag order by t1_0.tag
|
||||
OK (accessories, 1); (furniture, 2); (home, 2); (lighting, 2); (office, 3)
|
||||
|
||||
== json_set
|
||||
hql> update Product p set p.attributes = json_set(p.attributes, '$.color', 'green') where p.id = 1
|
||||
sql> update product p1_0 set attributes=json_set(p1_0.attributes,'$.color','green') where p1_0.id=1
|
||||
OK, 1 row(s) updated
|
||||
|
||||
== json_insert
|
||||
hql> update Product p set p.attributes = json_insert(p.attributes, '$.brand', 'Acme') where p.id = 1
|
||||
sql> update product p1_0 set attributes=json_insert(p1_0.attributes,'$.brand','Acme') where p1_0.id=1
|
||||
OK, 1 row(s) updated
|
||||
|
||||
== json_replace
|
||||
hql> update Product p set p.attributes = json_replace(p.attributes, '$.stock', 99) where p.id = 1
|
||||
sql> update product p1_0 set attributes=json_replace(p1_0.attributes,'$.stock',99) where p1_0.id=1
|
||||
OK, 1 row(s) updated
|
||||
|
||||
== json_remove
|
||||
hql> update Product p set p.attributes = json_remove(p.attributes, '$.price') where p.id = 1
|
||||
sql> update product p1_0 set attributes=json_remove(p1_0.attributes,'$.price') where p1_0.id=1
|
||||
OK, 1 row(s) updated
|
||||
|
||||
== json_mergepatch
|
||||
hql> update Product p set p.attributes = json_mergepatch(p.attributes, '{"stock": 1, "brand": null}') where p.id = 2
|
||||
sql> update product p1_0 set attributes=json_merge_patch(p1_0.attributes,'{"stock": 1, "brand": null}') where p1_0.id=2
|
||||
OK, 1 row(s) updated
|
||||
|
||||
== json_array_append, root path $
|
||||
hql> update Product p set p.tags = json_array_append(p.tags, '$', 'sale') where p.id = 1
|
||||
sql> update product p1_0 set tags=json_array_append(p1_0.tags,'$','sale') where p1_0.id=1
|
||||
OK, 1 row(s) updated
|
||||
|
||||
== json_array_append, nested path
|
||||
hql> update Product p set p.attributes = json_array_append(p.attributes, '$.nested.aisles', 11) where p.id = 1
|
||||
sql> update product p1_0 set attributes=json_array_append(p1_0.attributes,'$.nested.aisles',11) where p1_0.id=1
|
||||
OK, 1 row(s) updated
|
||||
|
||||
== json_array_insert
|
||||
hql> update Product p set p.tags = json_array_insert(p.tags, '$[0]', 'first') where p.id = 2
|
||||
sql> update product p1_0 set tags=json_array_insert(p1_0.tags,'$[0]','first') where p1_0.id=2
|
||||
OK, 1 row(s) updated
|
||||
|
||||
== attribute navigation p.attributes.color
|
||||
hql> select p.name from Product p where p.attributes.color = 'black'
|
||||
FAILED IllegalArgumentException: Could not interpret attribute 'color' of basic-valued path 'com.ankurm.jsoncolumns.model.Product(p).attributes'
|
||||
|
||||
== native JSON functions and operators (MySQL spellings)
|
||||
->> text extraction -> Floor lamp; Office chair
|
||||
JSON_CONTAINS(tags, '"office"') -> Desk lamp; Office chair; Monitor arm
|
||||
'office' MEMBER OF (tags) -> Desk lamp; Office chair; Monitor arm
|
||||
JSON_OVERLAPS(tags, '["home","accessories"]') -> Floor lamp; Bookshelf; Monitor arm
|
||||
JSON_CONTAINS_PATH(attributes, 'one', '$.nested.warehouse') -> 5
|
||||
|
||||
== @Embeddable as JSON on MySQL
|
||||
sql> select c1_0.name from customer c1_0 where cast(json_unquote(nullif(json_extract(c1_0.address,'$.city'),cast('null' as json))) as char)='Pune'
|
||||
result: [Asha]
|
||||
|
||||
== indexing 50,000 rows on MySQL
|
||||
rows: 50000
|
||||
|
||||
== no index
|
||||
sql> select count(*) from event where payload->>'$.userId' = '42'
|
||||
type=ALL key=null rows=49800 extra=Using where
|
||||
sql> select count(*) from event where cast(payload->>'$.userId' as unsigned) = 42
|
||||
type=ALL key=null rows=49800 extra=Using where
|
||||
sql> select count(*) from event where 'vip' member of (payload->'$.tags')
|
||||
type=ALL key=null rows=49800 extra=Using where
|
||||
|
||||
== functional index on cast(payload->>'$.userId' as unsigned), multi-valued index on tags
|
||||
sql> select count(*) from event where payload->>'$.userId' = '42'
|
||||
type=ALL key=null rows=49800 extra=Using where
|
||||
sql> select count(*) from event where cast(payload->>'$.userId' as unsigned) = 42
|
||||
type=ref key=idx_user rows=10 extra=null
|
||||
sql> select count(*) from event where 'vip' member of (payload->'$.tags')
|
||||
type=ref key=idx_tags rows=1000 extra=Using where
|
||||
count via index: 10 / vip rows: 1000
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
== which FormatMapper with only Jackson 3 on the classpath, nothing configured
|
||||
getJsonFormatMapper() -> org.hibernate.type.format.jackson.Jackson3JsonFormatMapper
|
||||
Jackson 2 present? false; Jackson 3 present? true
|
||||
|
||||
== schema evolution: stored JSON that does not match the record
|
||||
extra-key -> FAILED tools.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized property "colour" (class com.ankurm.jsoncolumns.model.Dimensions), not marked as ignorable (3 known properties: "widthMm", "heightMm", "depthMm")
|
||||
missing-key -> loaded Dimensions[widthMm=1, heightMm=0, depthMm=0]
|
||||
wrong-type -> FAILED tools.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `int` from String "wide": not a valid `int` value
|
||||
not-an-object -> FAILED tools.jackson.databind.exc.MismatchedInputException: Cannot deserialize value of type `com.ankurm.jsoncolumns.model.Dimensions` from Array value (token `JsonToken.START_ARRAY`)
|
||||
|
||||
== the fix for extra keys: @JsonIgnoreProperties(ignoreUnknown = true) on the record
|
||||
stored {"widthMm":7,"colour":"red"} -> loaded Tolerant[widthMm=7]
|
||||
|
||||
== hibernate.type.json_format_mapper = your own Jackson 3 JsonMapper (snake_case)
|
||||
dimensions = {"depth_mm": 3, "width_mm": 1, "height_mm": 2}
|
||||
specs = {"material": "tin", "weight_kg": 0.5, "certifications": []}
|
||||
read back = Dimensions[widthMm=1, heightMm=2, depthMm=3]
|
||||
|
||||
== java.time inside a JSON value with the default mapper
|
||||
Map.of("at", Instant) stored as {"at": 1793355330.000000000}
|
||||
read back as {at=1.79335533E9} (Double)
|
||||
@@ -0,0 +1,57 @@
|
||||
# Hibernate hibernate-core-7.4.14.Final.jar
|
||||
== A. Jackson 3 only, setting not set
|
||||
setting : hibernate.type.json_format_mapper = (not set)
|
||||
on classpath: jackson2=false jackson3=true jakarta.json=false
|
||||
mapper : org.hibernate.type.format.jackson.Jackson3JsonFormatMapper
|
||||
stored : {"n": 1, "list": ["a", "b"]} ; {"at": 1793355330.000000000, "label": "boot"}
|
||||
read back : {n=1, list=[a, b]} ; Stamp[label=boot, at=2026-10-30T10:15:30Z]
|
||||
|
||||
== B. Jackson 2 + Jackson 3, setting not set
|
||||
setting : hibernate.type.json_format_mapper = (not set)
|
||||
on classpath: jackson2=true jackson3=true jakarta.json=false
|
||||
mapper : org.hibernate.type.format.jackson.JacksonJsonFormatMapper
|
||||
FAILED : com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type `java.time.Instant` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (or disable `MapperFeature.REQUIRE_HANDLERS_FOR_JAVA8_TIMES`) (through reference chain: com.ankurm.jsoncolumns.FormatMapperProbe$Stamp["at"])
|
||||
|
||||
== C. Jackson 2 only, setting not set
|
||||
setting : hibernate.type.json_format_mapper = (not set)
|
||||
on classpath: jackson2=true jackson3=false jakarta.json=false
|
||||
mapper : org.hibernate.type.format.jackson.JacksonJsonFormatMapper
|
||||
FAILED : com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type `java.time.Instant` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (or disable `MapperFeature.REQUIRE_HANDLERS_FOR_JAVA8_TIMES`) (through reference chain: com.ankurm.jsoncolumns.FormatMapperProbe$Stamp["at"])
|
||||
|
||||
== D. no Jackson at all, setting not set
|
||||
setting : hibernate.type.json_format_mapper = (not set)
|
||||
on classpath: jackson2=false jackson3=false jakarta.json=false
|
||||
FAILED : org.hibernate.HibernateException: Could not find a FormatMapper for the JSON format, which is required for mapping JSON types. JSON FormatMapper configuration is automatic, but requires that you have either Jackson or a JSONB implementation like Yasson on the class path.
|
||||
|
||||
== E. Jackson 2 + Jackson 3, setting = jackson (Jackson 2)
|
||||
setting : hibernate.type.json_format_mapper = jackson
|
||||
on classpath: jackson2=true jackson3=true jakarta.json=false
|
||||
mapper : org.hibernate.type.format.jackson.JacksonJsonFormatMapper
|
||||
FAILED : com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type `java.time.Instant` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (or disable `MapperFeature.REQUIRE_HANDLERS_FOR_JAVA8_TIMES`) (through reference chain: com.ankurm.jsoncolumns.FormatMapperProbe$Stamp["at"])
|
||||
|
||||
== F. Jackson 3 only, setting = jackson (Jackson 2 absent)
|
||||
setting : hibernate.type.json_format_mapper = jackson
|
||||
on classpath: jackson2=false jackson3=true jakarta.json=false
|
||||
FAILED : java.lang.ClassNotFoundException: com.fasterxml.jackson.core.JsonProcessingException
|
||||
|
||||
== G. Jackson 2 + Jackson 3, setting = jackson3
|
||||
setting : hibernate.type.json_format_mapper = jackson3
|
||||
on classpath: jackson2=true jackson3=true jakarta.json=false
|
||||
mapper : org.hibernate.type.format.jackson.Jackson3JsonFormatMapper
|
||||
stored : {"n": 1, "list": ["a", "b"]} ; {"at": 1793355330.000000000, "label": "boot"}
|
||||
read back : {n=1, list=[a, b]} ; Stamp[label=boot, at=2026-10-30T10:15:30Z]
|
||||
|
||||
== H. Jackson 2 + jackson-datatype-jsr310, setting not set
|
||||
setting : hibernate.type.json_format_mapper = (not set)
|
||||
on classpath: jackson2=true jackson3=false jakarta.json=false
|
||||
mapper : org.hibernate.type.format.jackson.JacksonJsonFormatMapper
|
||||
stored : {"n": 1, "list": ["a", "b"]} ; {"at": 1793355330.000000000, "label": "boot"}
|
||||
read back : {n=1, list=[a, b]} ; Stamp[label=boot, at=2026-10-30T10:15:30Z]
|
||||
|
||||
== I. Jackson 2 + Jackson 3 + jsr310, setting not set
|
||||
setting : hibernate.type.json_format_mapper = (not set)
|
||||
on classpath: jackson2=true jackson3=true jakarta.json=false
|
||||
mapper : org.hibernate.type.format.jackson.JacksonJsonFormatMapper
|
||||
stored : {"n": 1, "list": ["a", "b"]} ; {"at": 1793355330.000000000, "label": "boot"}
|
||||
read back : {n=1, list=[a, b]} ; Stamp[label=boot, at=2026-10-30T10:15:30Z]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
== what the 2018-2022 recipes rely on, checked against Hibernate 7.4.14.Final
|
||||
org.hibernate.annotations.TypeDef present? false
|
||||
org.hibernate.annotations.TypeDefs present? false
|
||||
org.hibernate.annotations.Type attributes: [parameters, value]
|
||||
|
||||
== jars on the test classpath that mention jackson or hypersistence
|
||||
hypersistence-tsid-2.1.4.jar
|
||||
hypersistence-utils-hibernate-73-3.16.0.jar
|
||||
jackson-annotations-2.21.jar
|
||||
jackson-core-3.1.7.jar
|
||||
jackson-databind-3.1.7.jar
|
||||
spring-boot-jackson-4.1.2.jar
|
||||
spring-boot-starter-jackson-4.1.2.jar
|
||||
|
||||
== FormatMapper Hibernate picked with hypersistence-utils on the classpath
|
||||
getJsonFormatMapper() -> org.hibernate.type.format.jackson.Jackson3JsonFormatMapper
|
||||
|
||||
== @Type(JsonType.class) @Column(columnDefinition = "jsonb") on Hibernate 7.4.14
|
||||
stored: jsonb {"k": "v", "n": 1}
|
||||
in-place Map.put -> 1 UPDATE; stored: {"k": "changed", "n": 1}
|
||||
|
||||
== the built-in mapping on the same classpath: Product (Map, List, record, POJO) round trip
|
||||
round trip OK: Dimensions[widthMm=1, heightMm=2, depthMm=3] m
|
||||
@@ -0,0 +1,26 @@
|
||||
|
||||
== what the 2018-2022 recipes rely on, checked against Hibernate 7.4.14.Final
|
||||
org.hibernate.annotations.TypeDef present? false
|
||||
org.hibernate.annotations.TypeDefs present? false
|
||||
org.hibernate.annotations.Type attributes: [parameters, value]
|
||||
|
||||
== jars on the test classpath that mention jackson or hypersistence
|
||||
hypersistence-tsid-2.1.4.jar
|
||||
hypersistence-utils-hibernate-73-3.16.0.jar
|
||||
jackson-annotations-2.21.jar
|
||||
jackson-core-2.21.7.jar
|
||||
jackson-core-3.1.7.jar
|
||||
jackson-databind-2.22.4.jar
|
||||
jackson-databind-3.1.7.jar
|
||||
spring-boot-jackson-4.1.2.jar
|
||||
spring-boot-starter-jackson-4.1.2.jar
|
||||
|
||||
== FormatMapper Hibernate picked with hypersistence-utils on the classpath
|
||||
getJsonFormatMapper() -> org.hibernate.type.format.jackson.JacksonJsonFormatMapper
|
||||
|
||||
== @Type(JsonType.class) @Column(columnDefinition = "jsonb") on Hibernate 7.4.14
|
||||
stored: jsonb {"k": "v", "n": 1}
|
||||
in-place Map.put -> 1 UPDATE; stored: {"k": "changed", "n": 1}
|
||||
|
||||
== the built-in mapping on the same classpath: Product (Map, List, record, POJO) round trip
|
||||
round trip OK: Dimensions[widthMm=1, heightMm=2, depthMm=3] m
|
||||
@@ -0,0 +1,12 @@
|
||||
|
||||
== default Boot wiring
|
||||
JsonMapper bean from Boot : tools.jackson.databind.json.JsonMapper
|
||||
Hibernate FormatMapper : org.hibernate.type.format.jackson.Jackson3JsonFormatMapper
|
||||
same JsonMapper instance? : false
|
||||
native @> (findByAttributesContaining {"color":"black"}): [Floor lamp, Office chair]
|
||||
HQL json_value (findByColor black) : [Floor lamp, Office chair]
|
||||
derived query findByDimensions(new Dimensions(300,600,300)): [Floor lamp]
|
||||
@Modifying json_set repaint(1, "green") -> 1 row; color now green
|
||||
|
||||
== profile shared-mapper: HibernatePropertiesCustomizer hands Boot's JsonMapper to Hibernate
|
||||
same JsonMapper instance? : true
|
||||
@@ -0,0 +1,24 @@
|
||||
# hibernate-core-7.4.14.Final.jar
|
||||
|
||||
$ javap -p -c -cp hibernate-core-7.4.14.Final.jar org.hibernate.type.descriptor.java.spi.FormatMapperBasedJavaType (deepCopy only)
|
||||
public T deepCopy(T);
|
||||
Code:
|
||||
0: aload_1
|
||||
1: ifnonnull 8
|
||||
4: aconst_null
|
||||
5: goto 17
|
||||
8: aload_0
|
||||
9: aload_0
|
||||
10: aload_1
|
||||
11: invokevirtual #82 // Method toString:(Ljava/lang/Object;)Ljava/lang/String;
|
||||
14: invokevirtual #85 // Method fromString:(Ljava/lang/CharSequence;)Ljava/lang/Object;
|
||||
17: areturn
|
||||
|
||||
|
||||
$ javap -p -c -cp hibernate-core-7.4.14.Final.jar org.hibernate.type.descriptor.java.AbstractJavaType (areEqual only)
|
||||
public boolean areEqual(T, T);
|
||||
Code:
|
||||
0: aload_1
|
||||
1: aload_2
|
||||
2: invokestatic #51 // Method java/util/Objects.equals:(Ljava/lang/Object;Ljava/lang/Object;)Z
|
||||
5: ireturn
|
||||
@@ -0,0 +1,19 @@
|
||||
# Maven Central facts, read 2026-10-30
|
||||
|
||||
# maven-metadata.xml: <release> and <lastUpdated> (yyyyMMddHHmmss, UTC)
|
||||
com.vladmihalcea:hibernate-types-60 : release=2.21.1 lastUpdated=20221224094656
|
||||
com.vladmihalcea:hibernate-types-55 : release=2.21.1 lastUpdated=20221224094655
|
||||
com.vladmihalcea:hibernate-types-5 : release=2.21.1 lastUpdated=20221224094653
|
||||
io.hypersistence:hypersistence-utils-hibernate-71: release=3.16.0 lastUpdated=20260920123250
|
||||
io.hypersistence:hypersistence-utils-hibernate-73: release=3.16.0 lastUpdated=20260920123251
|
||||
|
||||
# jar Last-Modified on Central (upload time)
|
||||
hibernate-core-7.4.14.Final.jar: Sun, 25 Oct 2026 00:27:17 GMT
|
||||
spring-boot-4.1.2.jar: Thu, 22 Oct 2026 12:04:04 GMT
|
||||
hypersistence-utils-hibernate-73-3.16.0.jar: Sun, 20 Sep 2026 12:31:01 GMT
|
||||
|
||||
# hibernate.version that hypersistence-utils-hibernate-73:3.16.0 was built against (its pom)
|
||||
<hibernate.version>7.4.8.Final</hibernate.version>
|
||||
|
||||
# newest Jackson 3 databind on Central
|
||||
<release>3.2.4</release>
|
||||
@@ -0,0 +1,125 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.2</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>json-columns</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>json-columns</name>
|
||||
<description>JSON columns in Hibernate 7: @JdbcTypeCode(SqlTypes.JSON) on PostgreSQL 18 and MySQL 8.0, JSONB queries, indexing, dirty checking, format mappers</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<!-- Boot 4.1.2 manages 7.4.13.Final; this module overrides to the newest 7.x GA on Maven Central (checked 2026-10-30). -->
|
||||
<hibernate.version>7.4.14.Final</hibernate.version>
|
||||
<jackson2.version>2.22.4</jackson2.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<!-- Boot's JSON starter: provides the JsonMapper bean A11 compares with Hibernate's. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-jackson</artifactId>
|
||||
</dependency>
|
||||
<!-- Jackson 3 (tools.jackson), the one Spring Boot 4 puts on the classpath. -->
|
||||
<dependency>
|
||||
<groupId>tools.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<profiles>
|
||||
<!-- Adds Jackson 2 next to Jackson 3: which FormatMapper does Hibernate pick when both are present? -->
|
||||
<profile>
|
||||
<id>jackson2</id>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>${jackson2.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</profile>
|
||||
<!-- jackson-datatype-jsr310 for Jackson 2: the module Jackson 2 needs before java.time types work. -->
|
||||
<profile>
|
||||
<id>jsr310</id>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
<version>${jackson2.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</profile>
|
||||
<!-- hypersistence-utils: only to check whether its JsonType still works on Hibernate 7.4 and what it does to FormatMapper selection.
|
||||
Its test class lives in src/hypersistence-test so the default build does not need the jar. -->
|
||||
<profile>
|
||||
<id>hypersistence</id>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>io.hypersistence</groupId>
|
||||
<artifactId>hypersistence-utils-hibernate-73</artifactId>
|
||||
<version>3.16.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>build-helper-maven-plugin</artifactId>
|
||||
<version>3.6.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>add-hypersistence-tests</id>
|
||||
<phase>generate-test-sources</phase>
|
||||
<goals><goal>add-test-source</goal></goals>
|
||||
<configuration>
|
||||
<sources><source>src/hypersistence-test/java</source></sources>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<argLine>-Xmx768m</argLine>
|
||||
<forkCount>1</forkCount>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs FormatMapperProbe under five classpaths and writes output/09-format-mapper-matrix.txt.
|
||||
# Needs PostgreSQL up (scripts/start-databases.sh) and `mvn test-compile` done.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
OUT=output/09-format-mapper-matrix.txt
|
||||
mkdir -p output target/cp
|
||||
mvn -B -q test-compile >/dev/null 2>&1
|
||||
cp_for() { # name, extra mvn args...
|
||||
local name="$1"; shift
|
||||
mvn -B -q dependency:build-classpath -Dmdep.includeScope=test -Dmdep.outputFile="target/cp/$name.txt" "$@" >/dev/null 2>&1
|
||||
}
|
||||
drop() { tr ':' '\n' < "target/cp/$1.txt" | grep -v -E "$2" | paste -sd: - > "target/cp/$3.txt"; }
|
||||
cp_for j3 # Jackson 3 only: what Spring Boot 4 gives you
|
||||
cp_for j2j3 -Pjackson2 # Jackson 2 and Jackson 3 side by side
|
||||
cp_for j2j3t -Pjackson2,jsr310 # ... plus jackson-datatype-jsr310 for Jackson 2
|
||||
drop j2j3 '/tools/jackson/' j2 # Jackson 2 only
|
||||
drop j2j3t '/tools/jackson/' j2t # Jackson 2 only, with jsr310
|
||||
drop j3 '/tools/jackson/|/com/fasterxml/jackson/' none # no Jackson at all
|
||||
run() { # label, classpath-name, setting
|
||||
local label="$1" cpname="$2" setting="${3:-}"
|
||||
echo "== $label"
|
||||
java -cp "target/test-classes:target/classes:$(cat target/cp/$cpname.txt)" com.ankurm.jsoncolumns.FormatMapperProbe "$setting" 2>&1 \
|
||||
| grep -E '^(setting|on classpath|mapper|stored|read back|FAILED)'
|
||||
echo
|
||||
}
|
||||
{
|
||||
echo "# Hibernate $(grep -o 'hibernate-core-[0-9.A-Za-z]*' target/cp/j3.txt | head -1)"
|
||||
run "A. Jackson 3 only, setting not set" j3
|
||||
run "B. Jackson 2 + Jackson 3, setting not set" j2j3
|
||||
run "C. Jackson 2 only, setting not set" j2
|
||||
run "D. no Jackson at all, setting not set" none
|
||||
run "E. Jackson 2 + Jackson 3, setting = jackson (Jackson 2)" j2j3 jackson
|
||||
run "F. Jackson 3 only, setting = jackson (Jackson 2 absent)" j3 jackson
|
||||
run "G. Jackson 2 + Jackson 3, setting = jackson3" j2j3 jackson3
|
||||
run "H. Jackson 2 + jackson-datatype-jsr310, setting not set" j2t
|
||||
run "I. Jackson 2 + Jackson 3 + jsr310, setting not set" j2j3t
|
||||
} > "$OUT"
|
||||
cat "$OUT"
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Writes output/12-javap-deepcopy.txt: how Hibernate takes the snapshot it later compares with the live JSON attribute.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mkdir -p output
|
||||
JAR="$(mvn -B -q dependency:build-classpath -Dmdep.outputFile=/dev/stdout 2>/dev/null | tr ':' '\n' | grep 'hibernate-core-')"
|
||||
{
|
||||
echo "# $(basename "$JAR")"
|
||||
echo
|
||||
echo "\$ javap -p -c -cp $(basename "$JAR") org.hibernate.type.descriptor.java.spi.FormatMapperBasedJavaType (deepCopy only)"
|
||||
javap -p -c -cp "$JAR" org.hibernate.type.descriptor.java.spi.FormatMapperBasedJavaType 2>&1 | grep -v '^Picked up' \
|
||||
| sed -n '/ T deepCopy(T);/,/Serializable disassemble/p' | grep -v 'Serializable disassemble'
|
||||
echo
|
||||
echo "\$ javap -p -c -cp $(basename "$JAR") org.hibernate.type.descriptor.java.AbstractJavaType (areEqual only)"
|
||||
javap -p -c -cp "$JAR" org.hibernate.type.descriptor.java.AbstractJavaType 2>&1 | grep -v '^Picked up' \
|
||||
| sed -n '/boolean areEqual(T, T);/,/ireturn/p'
|
||||
} > output/12-javap-deepcopy.txt
|
||||
cat output/12-javap-deepcopy.txt
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Writes output/13-maven-facts.txt: release dates and metadata read straight from Maven Central (retries on HTTP 429).
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mkdir -p output
|
||||
M=https://repo1.maven.org/maven2
|
||||
get() { # url -> body (retry on 429/empty)
|
||||
for i in 1 2 3 4 5 6; do
|
||||
body="$(curl -sS --fail "$1" 2>/dev/null)" && [ -n "$body" ] && { printf '%s' "$body"; return 0; }
|
||||
sleep 20
|
||||
done
|
||||
return 1
|
||||
}
|
||||
head_lm() { # url -> Last-Modified
|
||||
for i in 1 2 3 4 5 6; do
|
||||
lm="$(curl -sSI "$1" 2>/dev/null | tr -d '\r' | grep -i '^last-modified:' | cut -d' ' -f2-)" && [ -n "$lm" ] && { echo "$lm"; return 0; }
|
||||
sleep 20
|
||||
done
|
||||
echo "unavailable"
|
||||
}
|
||||
meta() { # label path
|
||||
local x; x="$(get "$M/$2/maven-metadata.xml")" || { echo "$1: metadata unavailable"; return; }
|
||||
echo "$1: release=$(grep -o '<release>[^<]*' <<<"$x" | cut -d'>' -f2) lastUpdated=$(grep -o '<lastUpdated>[^<]*' <<<"$x" | cut -d'>' -f2)"
|
||||
sleep 3
|
||||
}
|
||||
{
|
||||
echo "# Maven Central facts, read $(date -u +%Y-%m-%d)"
|
||||
echo
|
||||
echo "# maven-metadata.xml: <release> and <lastUpdated> (yyyyMMddHHmmss, UTC)"
|
||||
meta "com.vladmihalcea:hibernate-types-60 " com/vladmihalcea/hibernate-types-60
|
||||
meta "com.vladmihalcea:hibernate-types-55 " com/vladmihalcea/hibernate-types-55
|
||||
meta "com.vladmihalcea:hibernate-types-5 " com/vladmihalcea/hibernate-types-5
|
||||
meta "io.hypersistence:hypersistence-utils-hibernate-71" io/hypersistence/hypersistence-utils-hibernate-71
|
||||
meta "io.hypersistence:hypersistence-utils-hibernate-73" io/hypersistence/hypersistence-utils-hibernate-73
|
||||
echo
|
||||
echo "# jar Last-Modified on Central (upload time)"
|
||||
for j in org/hibernate/orm/hibernate-core/7.4.14.Final/hibernate-core-7.4.14.Final.jar \
|
||||
org/springframework/boot/spring-boot/4.1.2/spring-boot-4.1.2.jar \
|
||||
io/hypersistence/hypersistence-utils-hibernate-73/3.16.0/hypersistence-utils-hibernate-73-3.16.0.jar; do
|
||||
echo "$(basename "$j"): $(head_lm "$M/$j")"; sleep 3
|
||||
done
|
||||
echo
|
||||
echo "# hibernate.version that hypersistence-utils-hibernate-73:3.16.0 was built against (its pom)"
|
||||
get "$M/io/hypersistence/hypersistence-utils-hibernate-73/3.16.0/hypersistence-utils-hibernate-73-3.16.0.pom" | grep -E '^\s*<hibernate.version>' | sed 's/^ *//'
|
||||
echo
|
||||
echo "# newest Jackson 3 databind on Central"
|
||||
get "$M/tools/jackson/core/jackson-databind/maven-metadata.xml" | grep -E '<release>' | sed 's/^ *//'
|
||||
} > output/13-maven-facts.txt
|
||||
cat output/13-maven-facts.txt
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates every file under output/. Needs JDK 25, Maven 3.9 with access to Maven Central, and the two database servers
|
||||
# from scripts/start-databases.sh (PostgreSQL on 55432, MySQL on 53306; override with PG_URL / MYSQL_URL).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
mvn -B -q clean >/dev/null
|
||||
./scripts/versions.sh >/dev/null
|
||||
./scripts/maven-facts.sh >/dev/null # release dates read from Maven Central
|
||||
./scripts/javap.sh >/dev/null # deepCopy / areEqual bytecode behind the dirty-checking results
|
||||
mvn -B test # A01-A09, A11: default classpath (Jackson 3 only)
|
||||
mvn -B test -Phypersistence -Dtest='A10*' # hypersistence-utils on a Jackson 3 only classpath
|
||||
mvn -B test -Phypersistence,jackson2 -Dtest='A10*' # ... and with Jackson 2 added
|
||||
./scripts/format-mapper-matrix.sh >/dev/null # nine classpath variants of the FormatMapper probe
|
||||
ls -1 output
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# Starts the two servers the tests talk to, without Docker. Idempotent. Defaults match Db.java.
|
||||
# PostgreSQL 18.6 : zonky embedded-postgres-binaries-linux-amd64 18.6.0 from Maven Central (initdb, pg_ctl, postgres only), port 55432
|
||||
# MySQL 8.0.x : Ubuntu packages mysql-server-core-8.0 + mysql-client-core-8.0, port 53306
|
||||
# Both refuse to run as root, so this script creates two throwaway OS users (pgj, myj). Run it as root in a disposable sandbox.
|
||||
set -euo pipefail
|
||||
BASE="${BASE:-/tmp/work/dbs}"
|
||||
PGPORT="${PGPORT:-55432}"; MYPORT="${MYPORT:-53306}"
|
||||
mkdir -p "$BASE/pg" "$BASE/mysql"
|
||||
id pgj >/dev/null 2>&1 || useradd -m pgj
|
||||
id myj >/dev/null 2>&1 || useradd -m myj
|
||||
|
||||
if [ ! -x "$BASE/pg/pg/bin/postgres" ]; then
|
||||
curl -sSfL -o "$BASE/pg/pg.jar" \
|
||||
https://repo1.maven.org/maven2/io/zonky/test/postgres/embedded-postgres-binaries-linux-amd64/18.6.0/embedded-postgres-binaries-linux-amd64-18.6.0.jar
|
||||
(cd "$BASE/pg" && unzip -o -q pg.jar && mkdir -p pg && tar -xJf postgres-linux-x86_64.txz -C pg)
|
||||
fi
|
||||
chmod -R a+rX "$BASE/pg"
|
||||
if [ ! -f "$BASE/pg/data/PG_VERSION" ]; then
|
||||
mkdir -p "$BASE/pg/data"; chown pgj "$BASE/pg/data"
|
||||
su pgj -c "cd /tmp && $BASE/pg/pg/bin/initdb -D $BASE/pg/data -U postgres -A trust -E UTF8 >/dev/null"
|
||||
fi
|
||||
su pgj -c "$BASE/pg/pg/bin/pg_ctl -D $BASE/pg/data -o '-p $PGPORT -k /tmp' -l $BASE/pg/data/pg.log -w start" || true
|
||||
|
||||
if ! command -v mysqld >/dev/null; then
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends mysql-server-core-8.0 mysql-client-core-8.0
|
||||
fi
|
||||
if [ ! -d "$BASE/mysql/data/mysql" ]; then
|
||||
mkdir -p "$BASE/mysql/data"; chown -R myj "$BASE/mysql"
|
||||
su myj -c "mysqld --no-defaults --initialize-insecure --datadir=$BASE/mysql/data >/dev/null 2>&1"
|
||||
fi
|
||||
chown -R myj "$BASE/mysql"
|
||||
if ! mysql -h127.0.0.1 -P"$MYPORT" -uroot -e 'select 1' >/dev/null 2>&1; then
|
||||
su myj -c "nohup mysqld --no-defaults --secure-file-priv=NULL --datadir=$BASE/mysql/data --port=$MYPORT \
|
||||
--socket=$BASE/mysql/my.sock --mysqlx=OFF --bind-address=127.0.0.1 --pid-file=$BASE/mysql/my.pid \
|
||||
--log-error=$BASE/mysql/err.log >/dev/null 2>&1 &"
|
||||
for _ in $(seq 1 40); do mysql -h127.0.0.1 -P"$MYPORT" -uroot -e 'select 1' >/dev/null 2>&1 && break; sleep 1; done
|
||||
fi
|
||||
psql -h 127.0.0.1 -p "$PGPORT" -U postgres -Atc 'select version()'
|
||||
mysql -h127.0.0.1 -P"$MYPORT" -uroot -Ne 'select version()'
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Writes output/00-versions.txt: the exact versions this module ran against.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mkdir -p output
|
||||
{
|
||||
echo "# java -version"
|
||||
java -version 2>&1 | grep -v '^Picked up'
|
||||
echo
|
||||
echo "# resolved libraries (mvn dependency:list, default profile)"
|
||||
mvn -B -q dependency:list -DincludeScope=test -DoutputFile=/dev/stdout 2>/dev/null \
|
||||
| grep -E 'hibernate-core|jackson-(databind|core|annotations)|postgresql:|mysql-connector-j|spring-boot:jar|spring-data-jpa|jakarta.persistence-api' | sed 's/^ *//' | sort
|
||||
echo
|
||||
echo "# PostgreSQL server"
|
||||
psql -h "${PGHOST:-127.0.0.1}" -p "${PGPORT:-55432}" -U postgres -Atc 'select version()'
|
||||
echo
|
||||
echo "# MySQL server"
|
||||
mysql -h127.0.0.1 -P"${MYPORT:-53306}" -uroot -Ne 'select version()'
|
||||
} > output/00-versions.txt
|
||||
cat output/00-versions.txt
|
||||
+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