@@ -148,7 +148,7 @@ private PostgresQueryParser createParser(Query query) {
148148
149149 @ Override
150150 public boolean upsert (Key key , Document document ) throws IOException {
151- throw new UnsupportedOperationException ( WRITE_NOT_SUPPORTED );
151+ return upsertWithRetry ( key , document , false );
152152 }
153153
154154 @ Override
@@ -325,7 +325,7 @@ public boolean bulkUpsert(Map<Key, Document> documents) {
325325
326326 // Build the bulk upsert SQL with all columns
327327 List <String > columnList = new ArrayList <>(allColumns );
328- String sql = buildBulkUpsertSql (columnList , quotedPkColumn );
328+ String sql = buildMergeUpsertSql (columnList , quotedPkColumn , false );
329329 LOGGER .debug ("Bulk upsert SQL: {}" , sql );
330330
331331 try (Connection conn = client .getPooledConnection ();
@@ -374,27 +374,34 @@ public boolean bulkUpsert(Map<Key, Document> documents) {
374374 }
375375
376376 /**
377- * Builds a PostgreSQL bulk upsert SQL statement for batch execution.
377+ * Builds a PostgreSQL upsert SQL statement with merge semantics.
378+ *
379+ * <p>Generates: INSERT ... ON CONFLICT DO UPDATE SET col = EXCLUDED.col for each column. Only
380+ * columns in the provided list are updated on conflict (merge behavior).
378381 *
379- * @param columns List of quoted column names (PK should be first)
382+ * @param columns List of quoted column names to include
380383 * @param pkColumn The quoted primary key column name
384+ * @param includeReturning If true, adds RETURNING clause to detect insert vs update
381385 * @return The upsert SQL statement
382386 */
383- private String buildBulkUpsertSql (List <String > columns , String pkColumn ) {
387+ private String buildMergeUpsertSql (
388+ List <String > columns , String pkColumn , boolean includeReturning ) {
384389 String columnList = String .join (", " , columns );
385390 String placeholders = String .join (", " , columns .stream ().map (c -> "?" ).toArray (String []::new ));
386391
387- // Build SET clause for non-PK columns: col = EXCLUDED.col (this ensures that on conflict, the
388- // new value is picked)
392+ // Build SET clause for non-PK columns: col = EXCLUDED.col
389393 String setClause =
390394 columns .stream ()
391395 .filter (col -> !col .equals (pkColumn ))
392396 .map (col -> col + " = EXCLUDED." + col )
393397 .collect (Collectors .joining (", " ));
394398
395- return String .format (
396- "INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s" ,
397- tableIdentifier , columnList , placeholders , pkColumn , setClause );
399+ String sql =
400+ String .format (
401+ "INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s" ,
402+ tableIdentifier , columnList , placeholders , pkColumn , setClause );
403+
404+ return includeReturning ? sql + " RETURNING (xmax = 0) AS is_insert" : sql ;
398405 }
399406
400407 @ Override
@@ -880,13 +887,81 @@ private boolean createOrReplaceWithRetry(Key key, Document document, boolean isR
880887 return executeUpsert (sql , parsed );
881888
882889 } catch (PSQLException e ) {
883- return handlePSQLExceptionForUpsert (e , key , document , tableName , isRetry );
890+ return handlePSQLExceptionForCreateOrReplace (e , key , document , tableName , isRetry );
884891 } catch (SQLException e ) {
885892 LOGGER .error ("SQLException in createOrReplace. key: {} content: {}" , key , document , e );
886893 throw new IOException (e );
887894 }
888895 }
889896
897+ /**
898+ * Upserts a document with merge semantics - only updates columns present in the document,
899+ * preserving existing values for columns not in the document.
900+ *
901+ * <p>Unlike {@link #createOrReplaceWithRetry}, this method does NOT reset missing columns to
902+ * their default values.
903+ *
904+ * @param key The document key
905+ * @param document The document to upsert
906+ * @param isRetry Whether this is a retry attempt after schema refresh
907+ * @return true if a new document was created, false if an existing document was updated
908+ */
909+ private boolean upsertWithRetry (Key key , Document document , boolean isRetry ) throws IOException {
910+ String tableName = tableIdentifier .getTableName ();
911+ List <String > skippedFields = new ArrayList <>();
912+
913+ try {
914+ TypedDocument parsed = parseDocument (document , tableName , skippedFields );
915+
916+ // Add the key as the primary key column
917+ String pkColumn = getPKForTable (tableName );
918+ String quotedPkColumn = PostgresUtils .wrapFieldNamesWithDoubleQuotes (pkColumn );
919+ PostgresDataType pkType = getPrimaryKeyType (tableName , pkColumn );
920+ parsed .add (quotedPkColumn , key .toString (), pkType , false );
921+
922+ List <String > docColumns = parsed .getColumns ();
923+
924+ String sql = buildUpsertSql (docColumns , quotedPkColumn );
925+ LOGGER .debug ("Upsert (merge) SQL: {}" , sql );
926+
927+ return executeUpsert (sql , parsed );
928+
929+ } catch (PSQLException e ) {
930+ return handlePSQLExceptionForUpsert (e , key , document , tableName , isRetry );
931+ } catch (SQLException e ) {
932+ LOGGER .error ("SQLException in upsert. key: {} content: {}" , key , document , e );
933+ throw new IOException (e );
934+ }
935+ }
936+
937+ /**
938+ * Builds a PostgreSQL upsert SQL statement with merge semantics.
939+ *
940+ * <p>This method constructs an atomic upsert query that:
941+ *
942+ * <ul>
943+ * <li>Inserts a new row if no conflict on the primary key
944+ * <li>If the row with that PK already exists, only updates columns present in the document
945+ * <li>Columns NOT in the document retain their existing values (merge behavior)
946+ * </ul>
947+ *
948+ * <p><b>Generated SQL pattern:</b>
949+ *
950+ * <pre>{@code
951+ * INSERT INTO table (col1, col2, pk_col)
952+ * VALUES (?, ?, ?)
953+ * ON CONFLICT (pk_col) DO UPDATE SET col1 = EXCLUDED.col1, col2 = EXCLUDED.col2
954+ * RETURNING (xmax = 0) AS is_insert
955+ * }</pre>
956+ *
957+ * @param docColumns columns present in the document
958+ * @param pkColumn The quoted primary key column name used for conflict detection
959+ * @return The complete upsert SQL statement with placeholders for values
960+ */
961+ private String buildUpsertSql (List <String > docColumns , String pkColumn ) {
962+ return buildMergeUpsertSql (docColumns , pkColumn , true );
963+ }
964+
890965 /**
891966 * Builds a PostgreSQL upsert (INSERT ... ON CONFLICT DO UPDATE) SQL statement.
892967 *
@@ -977,12 +1052,12 @@ private boolean executeUpsert(String sql, TypedDocument parsed) throws SQLExcept
9771052 }
9781053 }
9791054
980- private boolean handlePSQLExceptionForUpsert (
1055+ private boolean handlePSQLExceptionForCreateOrReplace (
9811056 PSQLException e , Key key , Document document , String tableName , boolean isRetry )
9821057 throws IOException {
9831058 if (!isRetry && shouldRefreshSchemaAndRetry (e .getSQLState ())) {
9841059 LOGGER .info (
985- "Schema mismatch detected during upsert (SQLState: {}), refreshing schema and retrying. key: {}" ,
1060+ "Schema mismatch detected during createOrReplace (SQLState: {}), refreshing schema and retrying. key: {}" ,
9861061 e .getSQLState (),
9871062 key );
9881063 schemaRegistry .invalidate (tableName );
@@ -992,6 +1067,21 @@ private boolean handlePSQLExceptionForUpsert(
9921067 throw new IOException (e );
9931068 }
9941069
1070+ private boolean handlePSQLExceptionForUpsert (
1071+ PSQLException e , Key key , Document document , String tableName , boolean isRetry )
1072+ throws IOException {
1073+ if (!isRetry && shouldRefreshSchemaAndRetry (e .getSQLState ())) {
1074+ LOGGER .info (
1075+ "Schema mismatch detected during upsert (SQLState: {}), refreshing schema and retrying. key: {}" ,
1076+ e .getSQLState (),
1077+ key );
1078+ schemaRegistry .invalidate (tableName );
1079+ return upsertWithRetry (key , document , true );
1080+ }
1081+ LOGGER .error ("SQLException in upsert. key: {} content: {}" , key , document , e );
1082+ throw new IOException (e );
1083+ }
1084+
9951085 private CreateResult handlePSQLExceptionForCreate (
9961086 PSQLException e , Key key , Document document , String tableName , boolean isRetry )
9971087 throws IOException {
0 commit comments