Для приготовления виджета Государственного Адресного Реестра, кроме базы, нам также понадобится nginx и его плагины postgres и json. Можно воспользоваться готовым образом.
В базе была определена большая функция gar_select от json и возвращающая json. Вот как раз она и нужна для использования через nginx.
Итак, сначала добавляем (в http-секцию вне server-секций) апстрим для подключения к базе:
upstream gar { # подключаемся к хосту postgres # на порт 5432 # к базе gar # под пользователем gar postgres_server host=postgres port=5432 dbname=gar user=gar; # задаём лог для апстрима postgres_log /var/log/nginx/gar.err; # будем держать по одному постоянному соединению с базой # (на каждый рабочий процеесс) postgres_keepalive 1; # будем держать по одному подготовленному оператору # (на каждое постоянное соединение с базой) postgres_prepare 1 overflow=deallocate; }
а в server-секцию добавляем использование
location =/gar_select.json { # задаём логи access_log /var/log/nginx/gar_select.json.log main; error_log /var/log/nginx/gar_select.json.err; # направляем запрос на определённый выше апстрим postgres_pass gar; # разрешаем использование подготовленных операторов postgres_prepare true; # задаём собственно запрос # (просто вызывая ту самую функцию gar_select, # передавая ей в качестве аргумента GET-параметры # в виде json) # ** сразу хочу предупредить: здесь НЕТ sql-инъекций! ** postgres_query "select gar_select($json_get_vars::json)"; # в результате выдаём json postgres_output json; }
Т.о. получаем что-то типа REST интерфейса к таблице gar, который будем использовать из JavaScript.
Сначала загрузим стили и скрипты в index.html
<html> <head> <meta http-equiv="content-type" content="text/html; charset=utf-8" /> <title>ГАР</title> <link rel="stylesheet" href="/static/bootstrap-5.1.1/css/bootstrap.css" /> <link rel="stylesheet" href="/static/select2-4.1.0-rc.0/css/select2.css" /> <script src="/static/jquery-3.4.1/jquery-3.4.1.js"></script> <script src="/static/bootstrap-5.1.1/js/bootstrap.js"></script> <script src="/static/select2-4.1.0-rc.0/js/select2.js"></script> <script src="/static/select2-4.1.0-rc.0/js/i18n/ru.js"></script> <script src="index.js"></script> </head> <body> <form action="gar_select.html" method="get"><table class="table"><tr> <td>gar-select:</td> <td><select id="id_label_single" class="gar-select" name="id" data-width="100%" data-placeholder="Адрес" data-initial="9ae64229-9f7b-4149-b27a-d1f6ec74b5ce" data-id="[b5701907-1537-4e52-b93a-d566a47086f7,daa79543-d4e1-4cf7-bd3a-5936e670aea8]"/></td> <td><input class="btn btn-primary" type="submit" value="OK"/></td> </tr></table></form> </body> </html>
а вот сам скрипт index.js
// после загрузки страницы $(document).ready(function() { // для каждого селекта с соответствующим классом $('select.gar-select').each(function(number, select) { // определяем функцию от элемента function data(item) {return { // возвращающую строку для select2 id: $.isArray(item) ? item[item.length - 1].id : item.id, item: item, text: $.isArray(item) ? item.map(function(item) {return item.text}).join(', ') : item.text, }} // а также функцию от элемента для его применения в select2 function select2(item) {$(select).data('select2').trigger('select', {data: data(item)})} // инициализируем select2 $(select).select2({ // будем делать запросы ajax: { // формируем GET-параметры для функции gar_select data: function(params) {return { full: !$.isEmptyObject(select.value) || ($.isEmptyObject(params.term) && $.isEmptyObject(select.value) && !$.isEmptyObject(select.dataset.id)), id: $.isEmptyObject(params.term) && $.isEmptyObject(select.value) && !$.isEmptyObject(select.dataset.id) ? select.dataset.id : undefined, limit: select.dataset.limit || 10, offset: ((params.page || 1) - 1) * (select.dataset.limit || 10), parent: !$.isEmptyObject(select.value) ? select.value : undefined, text: params.term, }}, dataType: 'json', // задаём тип результата delay: 300, // и задержку // задаём функцию обработки результата processResults: function(response, params) {return { results: response.data.map(data), pagination: {more: response.offset < response.count} }}, // адрес можно задать прямо в теге url: select.dataset.url || 'gar_select.json', }, allowClear: true, // показываем кнопку очистки closeOnSelect: false, // не закрываем при выборе dropdownParent: $(select).parent(), // располагаем в родителе language: 'ru', // использеум русский язык }) // при нажатии на кнопку очистки $(select).on('select2:clear', function(event) { // получаем элемент var item = event.params.data[0].item if ($.isArray(item)) { // и если ещё не дошли до верха item = item.slice(0, -1) // то показываем родителя if (item.length) {select2(item)} } // открываем выбор setTimeout(function() {$(select).select2('open')}) }) // при выборе $(select).on('select2:select', function(event) { // запускаем поиск $(select).data('select2').trigger('query', {}) // очищаем строку поиска $(select).parent().find('.select2-search__field').val('').trigger('focus') }) // если задан первоначальный уид if (select.dataset.initial) {$.ajax({ // выполняем запрос url: $(select).data('select2').options.options.ajax.url, dataType: $(select).data('select2').options.options.ajax.dataType, data: {id: select.dataset.initial} }).done(function(response) { // и выбираем результат select2(response.data) // закрываем выбор $(select).select2('close') })} }) })
Ещё, с помощью плагинов evaluate и mustach можно организовать html-интерфейс:
location =/gar_select.html { # задаём логи access_log /var/log/nginx/gar_select.html.log main; error_log /var/log/nginx/gar_select.html.err; # вычисляем соответсвующие под-запросы # в соотвествующие переменные evaluate $json /gar_select.json; evaluate $template /gar_select.template; # задаём данные для шаблонизатора mustach_json $json; # задаём шаблон mustach_template $template; # задаём тип результата mustach_content text/html; } location =/gar_select.template { access_log /var/log/nginx/gar_select.template.log main; error_log /var/log/nginx/gar_select.template.err; internal; }
Собственно сам шаблон gar_select.template
<html> <head> <meta http-equiv="content-type" content="text/html; charset=utf-8" /> <title>ГАР</title> <link rel="stylesheet" href="/static/bootstrap-5.1.1/css/bootstrap.min.css" /> <script src="/static/jquery-3.4.1/jquery-3.4.1.min.js"></script> <script src="/static/bootstrap-5.1.1/js/bootstrap.min.js"></script> <script> var limit = {{limit}}; var offset = {{offset}}; var count = {{count}}; </script> <script src="gar_select.js"></script> </head> <body> <form action="" method="get"> <input type="hidden" name="child" value="true"/> <table class="table table-bordered table-striped table-hover table-condensed text-nowrap"> <thead> <th class="id" title="id"><input style="width:100%" class="offset" placeholder="id" name="id" value='{{query.id}}'/></th> <th class="parent" title="parent"><input style="width:100%" class="offset" placeholder="parent" name="parent" value="{{query.parent}}"/></th> <th class="name" title="name"><input style="width:100%" class="offset" placeholder="name" name="name" value="{{query.name}}"/></th> <th class="short" title="short"><input style="width:100%" class="offset" placeholder="short" name="short" value="{{query.short}}"/></th> <th class="type" title="type"><input style="width:100%" class="offset" placeholder="type" name="type" value="{{query.type}}"/></th> <th class="post" title="post"><input style="width:100%" class="offset" placeholder="post" name="post" value="{{query.post}}"/></th> <th class="object" title="object"><input style="width:100%" class="offset" placeholder="object" name="object" value="{{query.object}}"/></th> <th class="region" title="region"><input style="width:100%" class="offset" placeholder="region" name="region" value="{{query.region}}"/></th> <th class="text" title="text"><input style="width:100%" class="offset" placeholder="text" name="text" value="{{query.text}}"/></th> <th class="child" title="child"><input style="width:100%" class="" type="submit" value="child"/></th> </thead> {{#data}} <tr> <td class="id" title="id">{{id}}</td> <td class="parent" title="parent"><a href="gar_select.html?child=true&id={{parent}}">{{parent}}</a></td> <td class="name" title="name">{{name}}</td> <td class="short" title="short">{{short}}</td> <td class="type" title="type">{{type}}</td> <td class="post" title="post">{{post}}</td> <td class="object" title="text">{{object}}</td> <td class="region" title="text">{{region}}</td> <td class="text" title="text">{{text}}</td> <td class="child" title="child"><input name="child" type="button" value="{{child}}" parent="{{id}}"/></td> </tr> {{/data}} </table> <table class="table"><tr> <td id="pagination"/> <td>limit: <input title="limit" class="offset" placeholder="limit" name="limit" value="{{limit}}"/></td> <td>offset: <input title="offset" class="" placeholder="offset" name="offset" value="{{offset}}"/></td> <td id="record"/><td>Всего {{count}}</td> </tr></table> </form> </body> </html>
и скрипт для пагинации gar_select.js
// после загрузки страницы document.addEventListener('DOMContentLoaded', function() { // вычисляем количество страниц var pages = Math.ceil(count / limit) // вычисляем текущую страницу var page = Math.ceil(offset / limit) + 1 // если все резульаты помещяются на одну страницу if (count <= limit) { // то скрываем пагинацию $('#pagination').parent().parent().hide() } // если есть результаты if (count > 0) { // то добавляем информацию об этом $('#record').append('Записи с ' + (offset + 1) + ' по ' + Math.min(offset + limit, count)) } else { // иначе - скрываем $('#record').hide() } // рассчитываем кнопки пагинации for (var pag = 1, more = false; pag <= pages; pag++) { if (pages <= 15 || pag <= 3 || pag >= pages - 2 || (page - 1 <= pag && pag <= page + 1)) { $('#pagination').append( $('<input>', { type: 'button', value: pag, disabled: pag == page, }).click(function(event) { $('input[name=offset]').val((this.value - 1) * limit) $('form').submit() }) ) more = false } else if (!more && (pag == 4 || pag == pages - 3)) { $('#pagination').append($('<span>').text('...')) more = true } } // при изменении в верхних фильтрах $('input.offset').change(function(event) { // сбрасываем офсет $('input[name=offset]').val(0) }) // по клике на дочерних $('input[name=child]').click(function(event) { // сбрасываем офсет $('input[name=offset]').val(0) // и всё, кроме количества $('input.offset').not('[name=limit]').val('') // задаём родителя $('input[name=parent]').val($(this).attr('parent')) // и отправляем форму $('form').submit() }) })
Как пример того, что плагин evaluate может обрабатывать вложенные под-запросы, и в качестве примера работы плагина htmldoc можно html преобразовать в pdf:
location =/gar_select.pdf { # задаём логи access_log /var/log/nginx/gar_select.pdf.log main; error_log /var/log/nginx/gar_select.pdf.err; # вычисляем под-запрос в переменную evaluate $html /gar_select.html; # преобразуем html в pdf html2pdf $html; }
Т.о., можно в любом месте, где нужен выбор адреса использовать виджет. Его же можно ипользовать и для поиска. Например, если нужно "найти все услуги на такой-то улице", то пользователь выбирает улицу в виджете, бакенд получает уид этой улицы, а по нему с помощью готовой функции gar_select_child получет уиды всех дочерних элементов (домов, квартир, комнат, ...) с помощью которых уже может фильтровать в своей базе.
-- создаём или меняем функцию от ... CREATE OR REPLACE FUNCTION gar_select_child(parent uuid, name text, short text, type text, post text, region text) RETURNS SETOF gar_view LANGUAGE sql STABLE AS $body$ with _ as ( with recursive _ as ( -- рекурсивно -- начиная сверху или с заданного элемента select gar.*, 0 as i from gar where (gar_select_child.parent is null and parent is null) or id = gar_select_child.parent union -- и продолжая вниз select gar.*, _.i + 1 as i from gar inner join _ on _.id = gar.parent ) select * from _ where i > 0 -- добавляя если нужно различные фильтры and (gar_select_child.name is null or name ilike gar_select_child.name||'%' or name ilike '% '||gar_select_child.name||'%' or name ilike '%-'||gar_select_child.name||'%' or name ilike '%.'||gar_select_child.name||'%') and (gar_select_child.short is null or short ilike gar_select_child.short) and (gar_select_child.type is null or case when gar_select_child.type ilike '{%}' then type = any(gar_select_child.type::text[]) else type ilike gar_select_child.type||'%' end) and (gar_select_child.post is null or post ilike gar_select_child.post||'%') and (gar_select_child.region is null or case when gar_select_child.region ilike '{%}' then region = any(gar_select_child.region::smallint[]) else region = gar_select_child.region::smallint end) order by i ) select id, parent, name, short, type, post, region, gar_text(name, short, type) AS text from _ order by to_number('0'||name, '999999999'), name; $body$;