Для приготовления виджета Государственного Адресного Реестра, кроме базы, нам также понадобится 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$;