diff --git a/CHANGELOG.md b/CHANGELOG.md index 111bbcfe..dfa7cd44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). - ## [UNRELEASED] +### Fixed +- Add datetime: null when an item has start_datetime and end_datetime but no datetime. Fixes (#158) + + ##[v0.9.9] ### Changed diff --git a/src/pgstac/sql/001a_jsonutils.sql b/src/pgstac/sql/001a_jsonutils.sql index 9fcb2375..2ff94ac6 100644 --- a/src/pgstac/sql/001a_jsonutils.sql +++ b/src/pgstac/sql/001a_jsonutils.sql @@ -108,12 +108,7 @@ BEGIN THEN RETURN j; ELSE - includes := includes || ( - CASE WHEN j ? 'collection' THEN - '["id","collection"]' - ELSE - '["id"]' - END)::jsonb; + includes := includes || '["id","collection"]'::jsonb; FOR path IN SELECT explode_dotpaths(includes) LOOP outj := jsonb_set_nested(outj, path, j #> path); END LOOP; @@ -151,21 +146,19 @@ CREATE OR REPLACE FUNCTION merge_jsonb(_a jsonb, _b jsonb) RETURNS jsonb AS $$ SELECT CASE WHEN _a = '"𒍟※"'::jsonb THEN NULL - WHEN _a IS NULL OR jsonb_typeof(_a) = 'null' THEN _b + WHEN _a IS NULL THEN _b WHEN jsonb_typeof(_a) = 'object' AND jsonb_typeof(_b) = 'object' THEN ( - SELECT - jsonb_strip_nulls( - jsonb_object_agg( - key, - merge_jsonb(a.value, b.value) - ) - ) - FROM - jsonb_each(coalesce(_a,'{}'::jsonb)) as a - FULL JOIN - jsonb_each(coalesce(_b,'{}'::jsonb)) as b - USING (key) + SELECT jsonb_object_agg(key, val) + FROM ( + SELECT key, merge_jsonb(a.value, b.value) AS val + FROM + jsonb_each(coalesce(_a,'{}'::jsonb)) as a + FULL JOIN + jsonb_each(coalesce(_b,'{}'::jsonb)) as b + USING (key) + ) sub + WHERE val IS NOT NULL ) WHEN jsonb_typeof(_a) = 'array' @@ -196,18 +189,16 @@ CREATE OR REPLACE FUNCTION strip_jsonb(_a jsonb, _b jsonb) RETURNS jsonb AS $$ WHEN _a = _b THEN NULL WHEN jsonb_typeof(_a) = 'object' AND jsonb_typeof(_b) = 'object' THEN ( - SELECT - jsonb_strip_nulls( - jsonb_object_agg( - key, - strip_jsonb(a.value, b.value) - ) - ) - FROM - jsonb_each(_a) as a - FULL JOIN - jsonb_each(_b) as b - USING (key) + SELECT jsonb_object_agg(key, val) + FROM ( + SELECT key, strip_jsonb(a.value, b.value) AS val + FROM + jsonb_each(_a) as a + FULL JOIN + jsonb_each(_b) as b + USING (key) + ) sub + WHERE val IS NOT NULL ) WHEN jsonb_typeof(_a) = 'array' diff --git a/src/pgstac/tests/basic/hydration.sql b/src/pgstac/tests/basic/hydration.sql new file mode 100644 index 00000000..88508762 --- /dev/null +++ b/src/pgstac/tests/basic/hydration.sql @@ -0,0 +1,23 @@ +-- Test that JSON null values survive the dehydrate/hydrate round-trip +SET ROLE pgstac_ingest; + +-- Setup: collection with empty base_item +INSERT INTO collections (content) VALUES ('{"id":"pgstactest-hydration"}'); + +-- Create item with explicit datetime:null (STAC-compliant temporal range) +SELECT create_item('{ + "id": "temporal-range-item", + "collection": "pgstactest-hydration", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": { + "datetime": null, + "start_datetime": "2026-01-01T00:00:00Z", + "end_datetime": "2026-01-31T23:00:00Z" + } +}'); + +-- Verify datetime:null is preserved in stored content +SELECT content->'properties'->'datetime' FROM items WHERE id='temporal-range-item'; + +-- Verify datetime:null is preserved after hydration +SELECT get_item('temporal-range-item', 'pgstactest-hydration')->'properties'->'datetime'; diff --git a/src/pgstac/tests/basic/hydration.sql.out b/src/pgstac/tests/basic/hydration.sql.out new file mode 100644 index 00000000..cbdc12a1 --- /dev/null +++ b/src/pgstac/tests/basic/hydration.sql.out @@ -0,0 +1,25 @@ +-- Test that JSON null values survive the dehydrate/hydrate round-trip +SET ROLE pgstac_ingest; +SET +-- Setup: collection with empty base_item +INSERT INTO collections (content) VALUES ('{"id":"pgstactest-hydration"}'); +INSERT 0 1 +-- Create item with explicit datetime:null (STAC-compliant temporal range) +SELECT create_item('{ + "id": "temporal-range-item", + "collection": "pgstactest-hydration", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": { + "datetime": null, + "start_datetime": "2026-01-01T00:00:00Z", + "end_datetime": "2026-01-31T23:00:00Z" + } +}'); + +-- Verify datetime:null is preserved in stored content +SELECT content->'properties'->'datetime' FROM items WHERE id='temporal-range-item'; + null + +-- Verify datetime:null is preserved after hydration +SELECT get_item('temporal-range-item', 'pgstactest-hydration')->'properties'->'datetime'; + null diff --git a/src/pgstac/tests/pgtap/003_items.sql b/src/pgstac/tests/pgtap/003_items.sql index ddebf80a..c4efdcac 100644 --- a/src/pgstac/tests/pgtap/003_items.sql +++ b/src/pgstac/tests/pgtap/003_items.sql @@ -52,3 +52,22 @@ SELECT results_eq($$ $$, 'Test delete_item function' ); + +-- merge_jsonb and strip_jsonb must preserve JSON null values +SELECT results_eq( + $$ SELECT merge_jsonb( + '{"properties": {"datetime": null, "start_datetime": "2026-01-01T00:00:00Z", "end_datetime": "2026-01-31T23:00:00Z"}}'::jsonb, + '{"properties": {}}'::jsonb + )->'properties'->'datetime' $$, + $$ SELECT 'null'::jsonb $$, + 'merge_jsonb preserves explicit JSON null values' +); + +SELECT results_eq( + $$ SELECT strip_jsonb( + '{"properties": {"datetime": null, "start_datetime": "2026-01-01T00:00:00Z", "end_datetime": "2026-01-31T23:00:00Z"}}'::jsonb, + '{"properties": {}}'::jsonb + )->'properties'->'datetime' $$, + $$ SELECT 'null'::jsonb $$, + 'strip_jsonb preserves explicit JSON null values' +);