Ekstraktor danych instrukcji MERGE (OracleDB) w IntelliJ

Autor
Damian
Terlecki
11 minut
Bazy danych

Wbudowana wtyczka do baz danych w IntelliJ, znacznie upraszcza codzienne zadania analityka i programisty aplikacji. Dzięki ekstraktorom danych możesz szybko wydobyć dane, używając jednego z dostępnych formatów. Jednakże, ponieważ niektóre bazy danych stosują niestandardową składnię dla bardziej złożonych operacji, takich jak np. upsert, ekstrakcja taka może okazać się nazbyt czasochłonna ze względu na brak wsparcia.

Ekstraktory danych z zakładki Database w IntelliJ Folder ze zdefiniowanymi ekstraktorami danych w IntelliJ

Na szczęście, interfejs ekstraktora danych w IntelliJ jest dosyć intuicyjne i jednocześnie pozwala na implementację większości podstawowych przypadków użycia. Wspomnianą operację upsert w bazie danych Oracle, przeprowadzilibyśmy zapewne przy użyciu instrukcji MERGE. Ogólna struktura tego polecenia, gdybyśmy chcieli go użyć do ekstrakcji danych np. do skryptu, wygląda tak:

MERGE INTO <table>
USING (<query>)
ON (<condition>)
WHEN MATCHED THEN UPDATE SET <column_updates>
WHEN NOT MATCHED THEN INSERT (<columns>) VALUES (<values>)

Ekstraktor danych w formie zapytań MERGE

Aby nie wynajdywać koła na nowo, spójrz na plik SQL-Insert-Statements.sql.groovy. Jest to ekstraktor dostarczany wraz z IntelliJi. W przeciwieństwie do innych wbudowanych ekstraktorów możesz go otworzyć i edytować. Znajdziesz go w drzewie projektu w sekcji Scratches and Consoles > Extensions > Database Tools and SQL > data > extractors. Zacznij od skopiowania go pod nową nazwą, na przykład SQL-Merge-Statements.sql.groovy.

Definicja zawiera już klauzulę INSERT oraz przetestowany sposób wypisywania kolejnych po przecinku wartości kolumn. Wykorzystajmy ponownie ten kod i rozłóżmy go na dwie funkcje – jedną wypisującą wartość kolumny oraz drugą dopisującą przecinek bądź inny wybrany znak (np. znak łączący kolejne warunki zapytania).

private void appendValue(dataRow, column, idx, columns) {
    def value = dataRow.value(column)
    def stringValue = value == null ? KW_NULL : FORMATTER.formatValue(value, column)
    def isStringLiteral = value != null && FORMATTER.isStringLiteral(value, column)
    if (isStringLiteral && DIALECT.getDbms().isMysql()) stringValue = stringValue.replace("\\", "\\\\")
    OUT.append(isStringLiteral ? (STRING_PREFIX + QUOTE) : "")
            .append(isStringLiteral ? stringValue.replace(QUOTE, QUOTE + QUOTE) : stringValue)
            .append(isStringLiteral ? QUOTE : "")
}

private void appendCharacter(character, idx, columns) {
    OUT.append(idx != columns.size() - 1 ? character : "")
}

Przed rozpoczęciem implementacji ustalmy kilka ważnych warunków, wpływających na to, jak będzie ona wyglądała. Przyjmijmy, że chcemy:

  • zaktualizować wszystkie kolumny (patrz komentarz #1);
  • użyć jednego źródła dla warunków, instrukcji UPDATE i INSERT, bez duplikowania wartości dla każdej klauzuli (patrz komentarz #2);
  • wygenerować warunek poprzez jedynie dla wybranych kolumn z wynikowego zestawu danych wyświetlonych w konsoli (#3);
  • poprawnie obsłużyć warunki dla wartości NULL (#4);
  • uniknąć błędu ORA-38104, który mówi, że kolumny wymienione w klauzuli ON muszą być pominięte w klauzuli UPDATE (#5).

Biorąc pod uwagę takie warunki, ekstrakcja pojedynczego rekordu może wyglądać w następujący sposób:

def record(columns, dataRow) {
    TABLE_NAME = TABLE == null ? "MY_TABLE" : TABLE
    OUT.append("MERGE INTO ").append(TABLE_NAME).append(NEWLINE)
    OUT.append("USING (SELECT ")
    ALL_COLUMNS.eachWithIndex { column, idx -> // #1 select to upsert all columns from a table
        appendValue(dataRow, column, idx, ALL_COLUMNS)
        OUT.append("  ").append(column.name())
        appendCharacter(SEP, idx, ALL_COLUMNS)
    }
    OUT.append(" FROM DUAL) upsert").append(NEWLINE) // #2 alias the source of the condition/update/insert 
    OUT.append("ON (")
    columns.eachWithIndex { column, idx -> // #3 upsert on condition created from selected columns
        OUT.append("(").append(TABLE_NAME).append(".").append(column.name()).append(" = ")
                .append("upsert.").append(column.name()).append(" OR (") // #4 handle nulls in the condition
                .append(TABLE_NAME).append(".").append(column.name()).append(" IS NULL AND ")
                .append("upsert.").append(column.name()).append(" IS NULL))")
        appendCharacter(' AND ', idx, columns)
    }
    OUT.append(")").append(NEWLINE)
    OUT.append(ALL_COLUMNS.stream() // #4 for the update statement, remove columns from the ON clause
            .filter(column -> columns.stream().noneMatch(selectedColumn -> selectedColumn.name().equals(column.name())))
            .map(nonOnReferencedColumn -> nonOnReferencedColumn.name() + " = upsert." + nonOnReferencedColumn.name())
            .reduce((updateColumn, updateColumn2) -> updateColumn + SEP + updateColumn2)
            .map(columnUpdates -> "WHEN MATCHED THEN UPDATE SET " + columnUpdates + NEWLINE)
            .orElse(""))
    OUT.append("WHEN NOT MATCHED THEN INSERT (")
    ALL_COLUMNS.eachWithIndex { column, idx ->
        OUT.append(column.name())
        appendCharacter(SEP, idx, ALL_COLUMNS)
    }
    OUT.append(") VALUES (")
    ALL_COLUMNS.eachWithIndex { column, idx ->
        OUT.append("upsert.").append(column.name())
        appendCharacter(SEP, idx, ALL_COLUMNS)
    }
    OUT.append(");").append(NEWLINE)
}

Tak zmodyfikowany ekstraktor pozwoli Ci na testowy eksport z tabelki USER_OBJECTS (po podmianie domyślnej nazwy tabeli MY_TABLE wygenerowanej ze względu na to, że zapytanie dotyczyło widoku):

MERGE INTO FOO
    USING (SELECT 'PIPELINE$1'                    OBJECT_NAME,
                  NULL                            SUBOBJECT_NAME,
                  69101                           OBJECT_ID,
                  NULL                            DATA_OBJECT_ID,
                  'JOB'                           OBJECT_TYPE,
                  TIMESTAMP '2022-03-27 07:51:29' CREATED
--...
           FROM DUAL) upsert
    ON ((FOO.SUBOBJECT_NAME = upsert.SUBOBJECT_NAME OR (FOO.SUBOBJECT_NAME IS NULL AND upsert.SUBOBJECT_NAME IS NULL)) AND
        (FOO.OBJECT_ID = upsert.OBJECT_ID OR (FOO.OBJECT_ID IS NULL AND upsert.OBJECT_ID IS NULL)))
    WHEN MATCHED THEN
        UPDATE
            SET OBJECT_NAME       = upsert.OBJECT_NAME,
                DATA_OBJECT_ID    = upsert.DATA_OBJECT_ID,
                OBJECT_TYPE       = upsert.OBJECT_TYPE,
                CREATED           = upsert.CREATED,
                LAST_DDL_TIME     = upsert.LAST_DDL_TIME,
                TIMESTAMP         = upsert.TIMESTAMP,
--...
    WHEN NOT MATCHED THEN
        INSERT (OBJECT_NAME, SUBOBJECT_NAME, OBJECT_ID, DATA_OBJECT_ID, OBJECT_TYPE, CREATED
--...
                )
        VALUES (upsert.OBJECT_NAME, upsert.SUBOBJECT_NAME, upsert.OBJECT_ID, upsert.DATA_OBJECT_ID
--...
                );

Tworzenie warunków na zaznaczonych kolumnach ułatwia dopasowanie upsertu do unikalnych ograniczeń. Ekstraktor możesz dalej dostosować do własnych przypadków użycia. Możesz na przykład poszukać sposobu zastąpienia identyfikatorów generatorem sekwencji. Pełny kod znajdziesz tutaj i w swej prostocie może nie pasować do każdego przypadku, dlatego zachęcam do dzielenia się spostrzeżeniami.