diff --git a/packages/database/features/addConcepts.feature b/packages/database/features/addConcepts.feature index a9b49e31f..6da9f6259 100644 --- a/packages/database/features/addConcepts.feature +++ b/packages/database/features/addConcepts.feature @@ -184,3 +184,123 @@ Feature: Concept upsert | _id | @is_relation | @arity | @_reference_content | @literal_content | | ns1 | false | 0 | {} | {} | | rts1 | true | 2 | {"destination": "ns1", "source": "ns1"} | {"roles":["source","destination"]} | + + # A client that knows nothing of a field must not erase what another client stored: + # in upsert_concepts, a value absent from the input leaves the stored one alone. + Scenario: Fields absent from an upsert keep their stored value + Given user user1 upserts these concepts to space s1: + """ + [ + { + "name": "Evidence", + "author_local_id": "user1", + "source_local_id": "ns1", + "created": "2000/01/01", + "last_modified": "2001/01/02", + "is_schema": true + }, + { + "name": "A Source", + "author_local_id": "user1", + "source_local_id": "n2", + "schema_represented_by_local_id": "ns1", + "created": "2000/01/03", + "last_modified": "2001/01/04" + }, + { + "name": "An Evidence", + "author_local_id": "user1", + "source_local_id": "n1", + "schema_represented_by_local_id": "ns1", + "description": "a description", + "epistemic_status": "certain", + "created": "2000/01/03", + "last_modified": "2001/01/04", + "literal_content": { + "roles": ["source"] + }, + "local_reference_content": { + "sourceDocument": "n2" + } + } + ] + """ + # A second client rewrites the same node, sending only what it knows about + When user user1 upserts these concepts to space s1: + """ + [ + { + "name": "An Evidence, renamed", + "source_local_id": "n1", + "created": "2000/01/03", + "last_modified": "2002/02/02" + } + ] + """ + Then a user logged in space s1 and calling getConcepts with these parameters: '{"scope":{"type":"nodes","nodeIds":["n1"]},"fields":{"concepts":["id","source_local_id","name","description","epistemic_status","author_id","schema_id","literal_content","reference_content"]}}' + And query results should look like this + | _id | name | description | epistemic_status | _author_id | _schema_id | @literal_content | @_reference_content | + | n1 | An Evidence, renamed | a description | certain | user1 | ns1 | {"roles": ["source"]} | {"sourceDocument": "n2"} | + + # The stored values are still clearable, by saying so explicitly + When user user1 upserts these concepts to space s1: + """ + [ + { + "name": "An Evidence, renamed", + "source_local_id": "n1", + "description": "", + "epistemic_status": "unknown", + "created": "2000/01/03", + "last_modified": "2003/03/03", + "literal_content": {}, + "local_reference_content": {} + } + ] + """ + Then a user logged in space s1 and calling getConcepts with these parameters: '{"scope":{"type":"nodes","nodeIds":["n1"]},"fields":{"concepts":["id","source_local_id","description","epistemic_status","literal_content","reference_content"]}}' + And query results should look like this + | _id | description | epistemic_status | @literal_content | @_reference_content | + | n1 | | unknown | {} | {} | + + # The link to a schema is the exception: absent keeps it, and it is erased by + # declaring the concept to be a schema itself. + Scenario: Declaring a concept to be a schema erases its link to a schema + Given user user1 upserts these concepts to space s1: + """ + [ + { + "name": "Evidence", + "author_local_id": "user1", + "source_local_id": "ns1", + "created": "2000/01/01", + "last_modified": "2001/01/02", + "is_schema": true + }, + { + "name": "An Evidence", + "author_local_id": "user1", + "source_local_id": "n1", + "schema_represented_by_local_id": "ns1", + "created": "2000/01/03", + "last_modified": "2001/01/04" + } + ] + """ + When user user1 upserts these concepts to space s1: + """ + [ + { + "name": "An Evidence", + "source_local_id": "n1", + "created": "2000/01/03", + "last_modified": "2002/02/02", + "is_schema": true + } + ] + """ + Then a user logged in space s1 and calling getConcepts with these parameters: '{"scope":{"schemas":true},"fields":{"concepts":["id","source_local_id","is_schema","schema_id","author_id"]}}' + And query results should look like this + | _id | @is_schema | _schema_id | _author_id | + | ns1 | true | | user1 | + | n1 | true | | user1 | diff --git a/packages/database/supabase/schemas/concept.sql b/packages/database/supabase/schemas/concept.sql index 7677dd40b..b0cd2520d 100644 --- a/packages/database/supabase/schemas/concept.sql +++ b/packages/database/supabase/schemas/concept.sql @@ -428,7 +428,9 @@ BEGIN data.schema_represented_by_local_id, concept.space_id) INTO concept.schema_id; END IF; concept.source_local_id = COALESCE(concept.source_local_id, data.represented_by_local_id); -- legacy input field - concept.reference_content := coalesce(data.reference_content, '{}'::jsonb); + -- NULL means "the input said nothing about references"; upsert_concepts preserves + -- the stored value in that case. An explicit '{}' still clears the references. + concept.reference_content := data.reference_content; IF data.local_reference_content IS NOT NULL THEN FOR key, value IN SELECT * FROM jsonb_each(data.local_reference_content) LOOP IF jsonb_typeof(value) = 'array' THEN @@ -444,7 +446,7 @@ BEGIN RAISE EXCEPTION 'Invalid value in local_reference_content % %', value, jsonb_typeof(value); END IF; END LOOP; - concept.reference_content := concept.reference_content || reference_content; + concept.reference_content := coalesce(concept.reference_content, '{}'::jsonb) || reference_content; END IF; RETURN concept; END; @@ -472,7 +474,10 @@ BEGIN FOR concept_row IN SELECT * FROM jsonb_array_elements(data) LOOP -- first set defaults - local_concept := jsonb_populate_record(NULL::public.concept_local_input, '{"epistemic_status": "unknown", "literal_content":{},"reference_content":{},"is_schema":false}'); + -- epistemic_status, literal_content and reference_content are deliberately left out of + -- the defaults: absent means "leave the stored value alone" on update, and only the + -- INSERT branch falls back to a default (see the ON CONFLICT clause below). + local_concept := jsonb_populate_record(NULL::public.concept_local_input, '{"is_schema":false}'); -- then input values local_concept := jsonb_populate_record(local_concept, concept_row); local_concept.space_id := v_space_id; @@ -489,19 +494,29 @@ BEGIN INSERT INTO public."Concept" ( epistemic_status, name, description, author_id, created, last_modified, space_id, schema_id, literal_content, is_schema, source_local_id, reference_content ) VALUES ( - db_concept.epistemic_status, db_concept.name, db_concept.description, db_concept.author_id, db_concept.created, db_concept.last_modified, db_concept.space_id, db_concept.schema_id, db_concept.literal_content, db_concept.is_schema, db_concept.source_local_id, db_concept.reference_content + coalesce(db_concept.epistemic_status, 'unknown'::public."EpistemicStatus"), db_concept.name, db_concept.description, db_concept.author_id, db_concept.created, db_concept.last_modified, db_concept.space_id, db_concept.schema_id, coalesce(db_concept.literal_content, '{}'::jsonb), db_concept.is_schema, db_concept.source_local_id, coalesce(db_concept.reference_content, '{}'::jsonb) ) ON CONFLICT (space_id, source_local_id) DO UPDATE SET - epistemic_status = db_concept.epistemic_status, + -- absent (NULL) values leave the stored value alone, so that a client that knows + -- nothing of these fields does not erase what another client set. + epistemic_status = coalesce(db_concept.epistemic_status, "Concept".epistemic_status), name = db_concept.name, - description = db_concept.description, - author_id = db_concept.author_id, + -- an empty string is how a client asks for the description to be cleared + description = CASE + WHEN db_concept.description = '' THEN NULL + ELSE coalesce(db_concept.description, "Concept".description) END, + -- a concept always has an author, so NULL here can only mean "not provided" + author_id = coalesce(db_concept.author_id, "Concept".author_id), created = db_concept.created, last_modified = db_concept.last_modified, - schema_id = db_concept.schema_id, - literal_content = db_concept.literal_content, + -- the link to a schema is only erased by declaring the concept to be a schema itself + schema_id = CASE + WHEN db_concept.is_schema THEN db_concept.schema_id + ELSE coalesce(db_concept.schema_id, "Concept".schema_id) END, + -- absent (NULL) content leaves the stored value alone; an explicit '{}' clears it + literal_content = coalesce(db_concept.literal_content, "Concept".literal_content), is_schema = db_concept.is_schema, - reference_content = db_concept.reference_content + reference_content = coalesce(db_concept.reference_content, "Concept".reference_content) -- If the syntax allowed two conflict clauses, I would add -- ON CONFLICT (space_id, name) DO NOTHING -- but since not, I have to handle it as an exception.