Запуск SQL запросов в SAP

в 8:51, , рубрики: abap, ERP-системы, sap, sql, testing tools, Программирование, разработка, тестирование

Запуск SQL запросов в SAP - 1 При внедрении информационных решений на базе SAP ERP, как правило, разворачиваются три системы:

1. Система разработки.
2. Система тестирования.
3. Система продуктивной эксплуатации.

В процессе разработки программ очень часто возникает необходимость оперативно протестировать SQL-запросы в продуктивной или тестовой системе, так как система разработки обычно содержит минимум данных и их не всегда достаточно. Давайте рассмотрим существующие для этого варианты, оценим их недостатки и в итоге разработаем свой инструмент.

Мне удалось насчитать 5 доступных вариантов:

1. Транзакция SE16/SE16N

С помощью этой транзакции можно делать выборку только с одной таблицы. Не подходит для запросов с несколькими таблицами.

2. Транзакция ST04 (Additional functions -> SQL Command Editor)

Этот инструмент позволяет выполнять SQL-запросы любой сложности, но имеет 2 недостатка:

  • во-первых, воспринимает только Native SQL-запросы (синтаксис СУБД), что накладывает некоторые неудобства, так как при разработке программ на ABAP для универсальности используются Open SQL-запросы, несколько отличающиеся синтаксисом, но это можно было бы пережить, если бы не «во-вторых»;
  • во-вторых, работает только в том случае, если в качестве СУБД используется Oracle.

3. Транзакция SQVI

В транзакции нельзя писать напрямую SQL-запросы, но можно с помощью конструктора строить достаточно сложные выборки из нескольких таблиц с JOIN`ами. Не умеет работать с подзапросами и к тому же в конструкторе приходится выполнять слишком много манипуляций мышкой, поэтому для тестирования запросов не подходит.

4. Написать простенькую программу с тестируемым запросом и перенести ее в тестовую систему

Процесс переноса измененного кода в тестовую (продуктивную) систему требует выполнения некоторых рутинных манипуляций и занимает в среднем 5-7 минут, поэтому данный вариант тоже не подходит, так как никакого терпения не хватит проделывать всё это после каждой правки запроса.

5. Прямой доступ к СУБД

В большинстве случаев получить разработчикам такой доступ на проектах не представляется возможным, поэтому данный вариант не подходит.

Вывод

Получается, что удобного универсального инструмента, который бы позволял оперативно тестировать SQL-запросы любой сложности в SAP, не существует. Придя к такому выводу, я решил разработать такой инструмент.

Приступаем к разработке

Для начала в транзакции SE80 создаем программу ZSQL, GUI-статус MAIN100 с кнопкой «Выполнить» и Экран 0100.

Укрупнённо алгоритм программы выглядит так:

Запуск SQL запросов в SAP - 2
Получение SQL-запроса SELECT

Для получения SQL-запроса будем использовать текстовый редактор, который создадим на экране с помощью класса CL_GUI_TEXTEDIT. Для этого добавим на Экран 0100 пустой контейнер с именем MYEDIT, в который будем выводить редактор.

Фрагмент кода, создающий текстовый редактор на экране

data: g_editor type ref to cl_gui_textedit,
      g_editor_container type ref to cl_gui_custom_container.

  if g_editor is initial.
    create object g_editor_container
      exporting
        container_name              = `MYEDIT`
      exceptions
        cntl_error                  = 1
        cntl_system_error           = 2
        create_error                = 3
        lifetime_error              = 4
        lifetime_dynpro_dynpro_link = 5.

    create object g_editor
      exporting
        parent                     = g_editor_container
        wordwrap_mode              = cl_gui_textedit=>wordwrap_at_fixed_position
        wordwrap_to_linebreak_mode = cl_gui_textedit=>true
      exceptions
        others                     = 1.

    if sy-subrc <> 0.
      leave program.
    endif.
  endif.

Парсинг SQL-запроса

Из введенного SQL-запроса нам необходимо получить список выбираемых полей и таблиц для того, чтобы в дальнейшем на основании этого списка динамически сгенерировать структуру ALV-Grid для вывода результата.

Фрагмент кода, анализирующий запрос

types: ty_simple_tab type standard table of ty_simple_struc.
types: ty_t_code type standard table of rssource-line.

data lt_sql_query type ty_simple_tab.
	 lt_fields type ty_simple_tab,
     lt_tables type ty_simple_tab,
     l_use_cnt(1) type c.

" Анализируем запрос построчно"
loop at lt_sql_query assigning <fs_sql_query>.
  " Удаляем нечитаемые спец-символы
  replace all occurrences of con_tab in <fs_sql_query>-line with space.
  concatenate ` ` <fs_sql_query>-line ` ` into <fs_sql_query>-line.
  
  " Разбиваем строку на отдельные слова"
 
  refresh lt_parsed_sql_line.

  split <fs_sql_query>-line at space into table lt_parsed_sql_line.
  delete lt_parsed_sql_line where line = ''.

  loop at lt_parsed_sql_line assigning <fs_parsed_sql_line>.
    translate <fs_parsed_sql_line>-line to upper case.

    if <fs_parsed_sql_line>-line = 'SELECT'.
      continue.
    endif.

    " Если дошли до * - считаем, что все выбираемые поля получены"

    if <fs_parsed_sql_line>-line = '*'.
      l_field_names_obtained = 'X'.
      continue.
    endif.

    " Если дошли до FROM или JOIN - считаем, что все выбираемые поля получены.
    " Следующее слово будет названием таблицы"

    if <fs_parsed_sql_line>-line = 'FROM' or <fs_parsed_sql_line>-line = 'JOIN'.
      l_field_names_obtained = 'X'.
      l_is_tabname = 'X'.
      continue.
    endif.

    " Получаем названия полей"

    if l_field_names_obtained is initial.
      " Ищем конструкцию COUNT()"

      find 'COUNT(' in <fs_parsed_sql_line>-line ignoring case.

      if sy-subrc = 0.
        l_use_cnt = 'X'.
        continue.
      endif.

      " Название поля указано с названием таблицы через ~"

      search <fs_parsed_sql_line>-line for '~'.

      if sy-subrc = 0.
        add 1 to sy-fdpos.
      endif.

      append <fs_parsed_sql_line>-line+sy-fdpos to lt_fields.
    endif.

    " Получаем названия таблиц"

    if l_is_tabname = 'X'.
      append <fs_parsed_sql_line>-line to lt_tables.
      clear l_is_tabname.
    endif.
  endloop.
endloop.

Выполнение SQL-запроса

Чтобы выполнить наш запрос, воспользуемся оператором generate subroutine pool, который позволяет динамически генерировать временные ABAP-программы на основании переданного в качестве параметра исходного кода, которым мы подготовим из введенного SQL-запроса.

Фрагмент кода, генерирующий ABAP-программу

types: ty_t_code type standard table of rssource-line.

data: code type ty_t_code,
      prog(8) type c,
      msg(120) type c,
      lt_parsed_sql_line type ty_simple_tab,
      l_sub_order(1) type c.

field-symbols: <fs_sql_query> type ty_simple_struc,
               <fs_parsed_sql_line> type ty_simple_struc.

append `program z_sql.` to code.
append `form get_data using fs_data type standard table.` to code.
append `try.` to code.

loop at lt_sql_query assigning <fs_sql_query>.
  clear: lt_parsed_sql_line.

  split <fs_sql_query>-line at space into table lt_parsed_sql_line.
  delete lt_parsed_sql_line where line = ''.

  loop at lt_parsed_sql_line assigning <fs_parsed_sql_line>.
    concatenate ` ` <fs_parsed_sql_line>-line ` ` into <fs_parsed_sql_line>-line.
    translate <fs_parsed_sql_line>-line to upper case.

    " добавляем into… только 1 раз, иначе будет добавляться во все подзапросы"

    if <fs_parsed_sql_line>-line = ' FROM ' and l_sub_order is initial.
      append `into corresponding fields of table fs_data` to code.

      l_sub_order = 'X'.
    endif.

    append <fs_parsed_sql_line>-line to code.
  endloop.
endloop.

append `.` to code.
append `rollback work.` to code.
append `catch cx_root.` to code.
append `rollback work.` to code.
append `message ``Что-то пошло не так, проверьте запрос`` type ``i``.` to code.
append `endtry.` to code.
append `endform.` to code.

generate subroutine pool code name prog
                              message msg.

Вывод результата на экран

Так как состав полей и их тип нам заранее неизвестны, то для получения результата и вывода его на экран нам необходимо динамически сгенерировать внутреннюю таблицу и структуру ALV-Grid на основании выбираемых в запросе полей. Для этого будем использовать метод create_dynamic_table класса cl_alv_table_create.

Фрагмент кода, генерирующий структуру ALV-Grid

data: ref_table_descr type ref to cl_abap_structdescr,
      lt_tab_struct type abap_compdescr_tab,
      ls_fieldcatalog type slis_fieldcat_alv.

field-symbols: <fs_tab_struct> type abap_compdescr,
               <fs_tables> type ty_simple_struc,
               <fs_fields> type ty_simple_struc.

loop at lt_tables assigning <fs_tables>.
  refresh lt_tab_struct.

  " Получаем все поля для выбираемой таблицы"

  ref_table_descr ?= cl_abap_typedescr=>describe_by_name( <fs_tables>-line ).
  lt_tab_struct[] = ref_table_descr->components[].

  loop at lt_tab_struct assigning <fs_tab_struct>.
    " если поля нет среди выбираемых в SQL-запросе - не выводим его не экран"

    if lines( lt_fields ) > 0.
      read table lt_fields transporting no fields with key line = <fs_tab_struct>-name.

      if sy-subrc <> 0.
        continue.
      endif.
    endif.

    " если поле с таким именем уже есть, то не добавляем повторно"

    read table lt_fieldcatalog transporting no fields with key fieldname = <fs_tab_struct>-name.

    if sy-subrc = 0.
      continue.
    endif.

    clear ls_fieldcatalog.
    ls_fieldcatalog-fieldname = <fs_tab_struct>-name.
    ls_fieldcatalog-ref_tabname = <fs_tables>-line.

    append ls_fieldcatalog to lt_fieldcatalog.
  endloop.
endloop.

" В запросе есть конструкция COUNT() – добавляем колонку с именем CNT и типом INT"

if l_use_cnt = 'X'.
  clear ls_fieldcatalog.

  ls_fieldcatalog-fieldname = 'CNT'.
  ls_fieldcatalog-seltext_l = 'Кол-во'.
  ls_fieldcatalog-seltext_m = 'Кол-во'.
  ls_fieldcatalog-seltext_s = 'Кол-во'.
  ls_fieldcatalog-datatype = 'INT4'.

  if p_tech_names = 'X'.
    ls_fieldcatalog-seltext_l = 'CNT'.
    ls_fieldcatalog-seltext_m = 'CNT'.
    ls_fieldcatalog-seltext_s = 'CNT'.
    ls_fieldcatalog-reptext_ddic = 'CNT'.
  endif.

  append ls_fieldcatalog to lt_fieldcatalog.
endif.

Фрагмент кода, создающий динамическую таблицу

data: dyn_table type ref to data,
      dyn_line type ref to data,
      lt_lvc_fieldcatalog type lvc_t_fcat,
      ls_lvc_fieldcatalog type lvc_s_fcat.

field-symbols: <fs_fieldcatalog> type slis_fieldcat_alv.

" Преобразуем данные в другой тип"

loop at lt_fieldcatalog assigning <fs_fieldcatalog>.
  clear ls_lvc_fieldcatalog.

  move-corresponding <fs_fieldcatalog> to ls_lvc_fieldcatalog.
  ls_lvc_fieldcatalog-ref_table = <fs_fieldcatalog>-ref_tabname.

  append ls_lvc_fieldcatalog to lt_lvc_fieldcatalog.
endloop.

" Создаем динамически таблицу"

call method cl_alv_table_create=>create_dynamic_table
  exporting
    it_fieldcatalog = lt_lvc_fieldcatalog
  importing
    ep_table        = dyn_table.

assign dyn_table->* to <fs_data>.
create data dyn_line like line of <fs_data>.
assign dyn_line->* to <fs_wa_data>.

Полный листинг исходного кода программы ZSQL:

Раскрыть

type-pools: slis.

types: begin of ty_simple_struc,
         line(255) type c,
       end of ty_simple_struc.

types: ty_simple_tab type standard table of ty_simple_struc.

types: ty_t_code type standard table of rssource-line.

data: g_editor type ref to cl_gui_textedit,
      g_editor_container type ref to cl_gui_custom_container,
      g_ok_code like sy-ucomm,
      p_tech_names(1) type c.

field-symbols: <fs_data> type standard table,
               <fs_wa_data> type any.

call screen 100.

module pbo output.
  set pf-status `MAIN100`.

  " Выводим на форму текстовый редактор для SQL-запроса"

  if g_editor is initial.
    create object g_editor_container
      exporting
        container_name              = `MYEDIT`
      exceptions
        cntl_error                  = 1
        cntl_system_error           = 2
        create_error                = 3
        lifetime_error              = 4
        lifetime_dynpro_dynpro_link = 5.

    create object g_editor
      exporting
        parent                     = g_editor_container
        wordwrap_mode              = cl_gui_textedit=>wordwrap_at_fixed_position
        wordwrap_to_linebreak_mode = cl_gui_textedit=>true
      exceptions
        others                     = 1.

    if sy-subrc <> 0.
      leave program.
    endif.
  endif.
endmodule.

module pai input.
  case sy-ucomm.
    when `EXIT`.
      leave program.
    when `EXEC`. " Нажатие кнопки «Выполнить»
      perform exec.
  endcase.
endmodule.
 
form exec.
  " Получаем введенный запрос с формы"

  data lt_sql_query type ty_simple_tab.
  clear lt_sql_query.

  call method g_editor->get_text_as_r3table
    importing
      table  = lt_sql_query
    exceptions
      others = 1.

  delete lt_sql_query where line = ''.

  " Парсим запрос и получаем названия выбираемых полей и таблиц"

  data: lt_fields type ty_simple_tab,
        lt_tables type ty_simple_tab,
        l_use_cnt(1) type c.

  clear: lt_fields, lt_tables, l_use_cnt.

  perform parse_sql_query
          using
            lt_sql_query
          changing
            lt_fields
            lt_tables
            l_use_cnt.

  " Генерируем ABAP-программу из полученного SQL-запроса"

  data: code type ty_t_code,
        prog(8) type c,
        msg(120) type c.

  clear: code, prog, msg.

  perform create_get_function
          using
            lt_sql_query
          changing
            code.

  generate subroutine pool code name prog
                                message msg.

  if sy-subrc <> 0.
    message msg type 'I'.
    return.
  endif.

  " Формируем структуру ALV-Grid на основе выбираемых полей и таблиц"

  data: lt_fieldcatalog type slis_t_fieldcat_alv.
  refresh: lt_fieldcatalog.

  perform get_fieldcat
          using
            lt_tables
            lt_fields
            p_tech_names
            l_use_cnt
          changing
            lt_fieldcatalog.

  " Динамически, на основе выбираемых полей и таблиц, создаем таблицу <fs_data>,
  " в которую будем помещать результат выполнения запроса"

  perform create_itab_dynamically
          using
            lt_fieldcatalog.

  " Выполняем SQL-запрос, вызывая функцию из сгенерированной программы"

  perform get_data in program (prog)
          using
            <fs_data>.

  " Выводим результат на экран"

  perform show_alv
          using
            lt_fieldcatalog.
endform. 

" Функция разбора запроса"

form parse_sql_query
     using
       lt_sql_query type ty_simple_tab
     changing
       lt_fields type ty_simple_tab
       lt_tables type ty_simple_tab
       l_use_cnt.

  data: l_field_names_obtained(1) type c,
        l_is_tabname(1) type c,
        lt_parsed_sql_line type ty_simple_tab.

  clear: l_field_names_obtained, l_is_tabname.

  field-symbols: <fs_sql_query> type ty_simple_struc,
                 <fs_parsed_sql_line> type ty_simple_struc.

  constants: con_tab type c value cl_abap_char_utilities=>horizontal_tab.

  " Анализируем запрос построчно"

  loop at lt_sql_query assigning <fs_sql_query>.
    " Удаляем нечитаемые спец-символы
    replace all occurrences of con_tab in <fs_sql_query>-line with space.
    concatenate ` ` <fs_sql_query>-line ` ` into <fs_sql_query>-line.
    
    " Разбиваем строку на отдельные слова"
    
    refresh lt_parsed_sql_line.

    split <fs_sql_query>-line at space into table lt_parsed_sql_line.
    delete lt_parsed_sql_line where line = ''.

    loop at lt_parsed_sql_line assigning <fs_parsed_sql_line>.
      translate <fs_parsed_sql_line>-line to upper case.

      if <fs_parsed_sql_line>-line = 'SELECT'.
        continue.
      endif.

      " Если дошли до * - считаем, что все выбираемые поля получены"

      if <fs_parsed_sql_line>-line = '*'.
        l_field_names_obtained = 'X'.
        continue.
      endif.

      " Если дошли до FROM или JOIN - считаем, что все выбираемые поля получены.
      " Следующее слово будет названием таблицы"

      if <fs_parsed_sql_line>-line = 'FROM' or <fs_parsed_sql_line>-line = 'JOIN'.
        l_field_names_obtained = 'X'.
        l_is_tabname = 'X'.
        continue.
      endif.

      " Получаем названия полей"

      if l_field_names_obtained is initial.
        " Ищем конструкцию COUNT()"

        find 'COUNT(' in <fs_parsed_sql_line>-line ignoring case.

        if sy-subrc = 0.
          l_use_cnt = 'X'.
          continue.
        endif.

        " Название поля указано с названием таблицы через ~"

        search <fs_parsed_sql_line>-line for '~'.

        if sy-subrc = 0.
          add 1 to sy-fdpos.
        endif.

        append <fs_parsed_sql_line>-line+sy-fdpos to lt_fields.
      endif.

      " Получаем названия таблиц"

      if l_is_tabname = 'X'.
        append <fs_parsed_sql_line>-line to lt_tables.
        clear l_is_tabname.
      endif.
    endloop.
  endloop.
endform.                    

" Функция создания исходного кода ABAP-программы для последующей генерации"

form create_get_function
     using
       lt_sql_query type ty_simple_tab
     changing
       code type ty_t_code.

  data: lt_parsed_sql_line type ty_simple_tab,
        l_sub_order(1) type c.

  clear l_sub_order.

  field-symbols: <fs_sql_query> type ty_simple_struc,
                 <fs_parsed_sql_line> type ty_simple_struc.

  append `program z_sql.` to code.
  append `form get_data using fs_data type standard table.` to code.
  append `try.` to code.

  loop at lt_sql_query assigning <fs_sql_query>.
    clear: lt_parsed_sql_line.

    split <fs_sql_query>-line at space into table lt_parsed_sql_line.
    delete lt_parsed_sql_line where line = ''.

    loop at lt_parsed_sql_line assigning <fs_parsed_sql_line>.
      concatenate ` ` <fs_parsed_sql_line>-line ` ` into <fs_parsed_sql_line>-line.
      translate <fs_parsed_sql_line>-line to upper case.

      " добавляем into… только 1 раз, иначе будет добавляться во все подзапросы"

      if <fs_parsed_sql_line>-line = ' FROM ' and l_sub_order is initial.
        append `into corresponding fields of table fs_data` to code.

        l_sub_order = 'X'.
      endif.

      append <fs_parsed_sql_line>-line to code.
    endloop.
  endloop.

  append `.` to code.
  append `rollback work.` to code.
  append `catch cx_root.` to code.
  append `rollback work.` to code.
  append `message ``Что-то пошло не так, проверьте запрос`` type ``i``.` to code.
  append `endtry.` to code.
  append `endform.` to code.
endform.

" Функция генерации структуры ALV-грида"

form get_fieldcat
     using
       lt_tables type ty_simple_tab
       lt_fields type ty_simple_tab
       p_tech_names
       l_use_cnt
     changing
       lt_fieldcatalog type slis_t_fieldcat_alv.

  data: ref_table_descr type ref to cl_abap_structdescr,
        lt_tab_struct type abap_compdescr_tab,
        ls_fieldcatalog type slis_fieldcat_alv.

  field-symbols: <fs_tab_struct> type abap_compdescr,
                 <fs_tables> type ty_simple_struc,
                 <fs_fields> type ty_simple_struc.

  loop at lt_tables assigning <fs_tables>.
    refresh lt_tab_struct.

    " Получаем все поля для выбираемой таблицы"

    ref_table_descr ?= cl_abap_typedescr=>describe_by_name( <fs_tables>-line ).
    lt_tab_struct[] = ref_table_descr->components[].

    loop at lt_tab_struct assigning <fs_tab_struct>.
      " если поля нет среди выбираемых в SQL-запросе - не выводим его не экран"

      if lines( lt_fields ) > 0.
        read table lt_fields transporting no fields with key line = <fs_tab_struct>-name.

        if sy-subrc <> 0.
          continue.
        endif.
      endif.

      " если поле с таким именем уже есть, то не добавляем повторно"

      read table lt_fieldcatalog transporting no fields with key fieldname = <fs_tab_struct>-name.

      if sy-subrc = 0.
        continue.
      endif.

      clear ls_fieldcatalog.
      ls_fieldcatalog-fieldname = <fs_tab_struct>-name.
      ls_fieldcatalog-ref_tabname = <fs_tables>-line.

      append ls_fieldcatalog to lt_fieldcatalog.
    endloop.
  endloop.

  " В запросе есть конструкция COUNT() – добавляем колонку с именем CNT и типом INT"

  if l_use_cnt = 'X'.
    clear ls_fieldcatalog.

    ls_fieldcatalog-fieldname = 'CNT'.
    ls_fieldcatalog-seltext_l = 'Кол-во'.
    ls_fieldcatalog-seltext_m = 'Кол-во'.
    ls_fieldcatalog-seltext_s = 'Кол-во'.
    ls_fieldcatalog-datatype = 'INT4'.

    if p_tech_names = 'X'.
      ls_fieldcatalog-seltext_l = 'CNT'.
      ls_fieldcatalog-seltext_m = 'CNT'.
      ls_fieldcatalog-seltext_s = 'CNT'.
      ls_fieldcatalog-reptext_ddic = 'CNT'.
    endif.

    append ls_fieldcatalog to lt_fieldcatalog.
  endif.
endform.

" Функция создания динамической внутренней таблицы"

form create_itab_dynamically
     using
       lt_fieldcatalog type slis_t_fieldcat_alv.

  data: dyn_table type ref to data,
        dyn_line type ref to data,
        lt_lvc_fieldcatalog type lvc_t_fcat,
        ls_lvc_fieldcatalog type lvc_s_fcat.

  field-symbols: <fs_fieldcatalog> type slis_fieldcat_alv.

  " Преобразуем данные в другой тип"

  loop at lt_fieldcatalog assigning <fs_fieldcatalog>.
    clear ls_lvc_fieldcatalog.

    move-corresponding <fs_fieldcatalog> to ls_lvc_fieldcatalog.
    ls_lvc_fieldcatalog-ref_table = <fs_fieldcatalog>-ref_tabname.

    append ls_lvc_fieldcatalog to lt_lvc_fieldcatalog.
  endloop.

  " Создаем динамически таблицу"

  call method cl_alv_table_create=>create_dynamic_table
    exporting
      it_fieldcatalog = lt_lvc_fieldcatalog
    importing
      ep_table        = dyn_table.

  assign dyn_table->* to <fs_data>.
  create data dyn_line like line of <fs_data>.
  assign dyn_line->* to <fs_wa_data>.
endform.

" Функция отображения ALV-Grid на экране"

form show_alv
     using
       lt_fieldcatalog type slis_t_fieldcat_alv.

  data: ls_event type slis_alv_event,
        lt_event type slis_t_event,
        ls_layout type slis_layout_alv,
        l_repid like sy-repid.

  ls_layout-colwidth_optimize = 'X'.
  l_repid = sy-repid.

  call function 'REUSE_ALV_GRID_DISPLAY'
    exporting
      i_callback_program      = l_repid
      is_layout               = ls_layout
      it_fieldcat             = lt_fieldcatalog
      i_save                  = 'X'
    tables
      t_outtab                = <fs_data>
    exceptions
      program_error           = 1
      others                  = 2.

  if sy-subrc <> 0.
    leave program.
  endif.
endform.

Разработанная программа позволяет выполнять Open SQL-запросы SELECT любой сложности. Только нужно соблюдать одно правило при написании запроса: если используется конструкция COUNT(), то после нее нужно дописывать «AS cnt», чтобы корректно сгенерировался ALV-Grid.

Программу, по идее, можно немного доработать и использовать не только для тестирования запросов, но и для формирования пользовательских отчетов.

В статье я не затрагивал вопросы безопасности. Входящий запрос никак не проверяется на корректность, после него можно написать любой ABAP-код и он будет выполняться. Для исключения такой возможности достаточно дописать несложные проверки.

Пользуйтесь!

Автор: irvil

Источник


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js