| | 1 | = Напредна тема: ГИС (просторни податоци со PostGIS) = |
| | 2 | |
| | 3 | Како напредна тема инфраструктурниот модул (`or_`) е надграден во вистински географско-информациски (ГИС) потсистем преку '''PostGIS'''. Наместо објектите да чуваат „простор" како текст, тие сега чуваат реална геометрија со SRID, индексирана со GiST, и заштитена со просторни тригери. |
| | 4 | |
| | 5 | [attachment:14_gis.sql 14_gis.sql] |
| | 6 | |
| | 7 | |
| | 8 | == Мотивација и опфат == |
| | 9 | |
| | 10 | Просторното мапирање на објектите (згради, катови, простории, точки на интерес) е централно за инфраструктурниот модул. За тоа да носи реална вредност — содржаност, растојание, површина, спречување преклопување — просторот мора да биде разбирлив за базата, а не само текстуален запис. Надградбата опфаќа: |
| | 11 | |
| | 12 | * миграција на геометријата на `or_mapa_objekt` од текст во `geometry(Geometry, 4326)` |
| | 13 | * енумерација `mapa_geom_tip` изведен автоматски од самата геометрија |
| | 14 | * GiST просторен индекс |
| | 15 | * 2 функции, 3 тригери и 1 процедура со просторна логика |
| | 16 | * хиерархиска и просторна валидација (кампус → зграда → кат → просторија) |
| | 17 | |
| | 18 | |
| | 19 | == Дизајнерски одлуки == |
| | 20 | |
| | 21 | === 1. Отстранет `or_tip_mapa_objekt` === |
| | 22 | |
| | 23 | Табелата `or_tip_mapa_objekt` беше замислена да ги чува геометриските типови (POINT, LINESTRING, POLYGON, MULTIPOLYGON). Бидејќи PostGIS веќе го знае типот на секоја геометрија преку `GeometryType(geom)`, одделен шифрарник со надворешен клуч е чист вишок. Затоа табелата, колоната `map_object_type_id` и ограничувањето `fk_mapa_obj_tip` се отстранети. (Семантичката класификација на објектите — зграда, просторија — и онака живее во `or_tip_objekt_organizacija`.) |
| | 24 | |
| | 25 | === 2. Текст → `geometry(Geometry, 4326)` === |
| | 26 | |
| | 27 | Колоната `spatial_tekst text` е заменета со `geom geometry(Geometry, 4326)`. SRID '''4326''' е WGS84 (географска должина/ширина во степени). Типот `Geometry` (генерички) дозволува различни видови геометрија во иста табела — полигони за отпечатоци и точки за локации. |
| | 28 | |
| | 29 | === 3. Изведен енум `mapa_geom_tip` (преку тригер, не GENERATED) === |
| | 30 | |
| | 31 | Типот на геометријата се изложува преку енум `mapa_geom_tip`, но вредноста '''не се внесува рачно''' — се изведува автоматски од геометријата, па никогаш не може да се разликува од податокот. Првично беше пробана `GENERATED` колона со израз `GeometryType(geom)::mapa_geom_tip`, но PostGIS го одби бидејќи изразот не е `IMMUTABLE`: |
| | 32 | |
| | 33 | {{{ |
| | 34 | ERROR: generation expression is not immutable |
| | 35 | }}} |
| | 36 | |
| | 37 | Затоа решението е `BEFORE` тригер (`trg_postavi_geom_tip`) кој ја поставува вредноста при секој внес/измена на геометријата — иста гаранција, без барањето за имутабилност. |
| | 38 | |
| | 39 | |
| | 40 | == Надградба на шемата == |
| | 41 | |
| | 42 | Енумот мора да постои пред табелата што го користи: |
| | 43 | |
| | 44 | {{{ |
| | 45 | CREATE TYPE mapa_geom_tip AS ENUM ('POINT', 'LINESTRING', 'POLYGON', 'MULTIPOLYGON'); |
| | 46 | }}} |
| | 47 | |
| | 48 | Структурата на `or_mapa_objekt` по надградбата: |
| | 49 | |
| | 50 | {{{ |
| | 51 | id integer (identity, PRIMARY KEY) |
| | 52 | geom geometry(Geometry, 4326) -- вистинска геометрија (WGS84) |
| | 53 | geom_tip mapa_geom_tip -- изведен од geom преку тригер |
| | 54 | kapacitet integer -- пресметан од површина |
| | 55 | parent_mapa_objekt_id integer → or_mapa_objekt(id) -- просторна хиерархија |
| | 56 | }}} |
| | 57 | |
| | 58 | * '''geom''' — отпечаток (полигон) или локација (точка) во SRID 4326. |
| | 59 | * '''geom_tip''' — енум синхронизиран со геометријата (преку тригер 8.1). |
| | 60 | * '''kapacitet''' — се полни од површината преку процедурата. |
| | 61 | * '''parent_mapa_objekt_id''' — самореференца за дрвото кампус → зграда → кат → просторија. |
| | 62 | |
| | 63 | |
| | 64 | == Функции == |
| | 65 | |
| | 66 | Функциите враќаат вредност и се вградуваат директно во прашалник, поглед или друга рутина. |
| | 67 | |
| | 68 | === 1. fn_povrshina_m2 === |
| | 69 | |
| | 70 | {{{ |
| | 71 | CREATE OR REPLACE FUNCTION fn_povrshina_m2(p_mapa_objekt_id integer) |
| | 72 | RETURNS numeric |
| | 73 | LANGUAGE sql |
| | 74 | STABLE |
| | 75 | AS $$ |
| | 76 | SELECT ROUND(ST_Area(geom::geography)::numeric, 2) |
| | 77 | FROM or_mapa_objekt |
| | 78 | WHERE id = p_mapa_objekt_id; |
| | 79 | $$; |
| | 80 | }}} |
| | 81 | |
| | 82 | Функцијата `fn_povrshina_m2` ја враќа површината на објектот во '''квадратни метри'''. Бидејќи SRID 4326 е во степени, геометријата се кастира во `geography` за да се добие геодезиска површина во метри (`ST_Area` врз чиста geometry би дал степени²). За објекти што немаат површина (POINT, LINESTRING) враќа 0. Реализирана е како функција бидејќи враќа единечна вредност што се вградува во прашалници и во процедурата за капацитет. |
| | 83 | |
| | 84 | === 2. fn_najblisku_objekti === |
| | 85 | |
| | 86 | {{{ |
| | 87 | CREATE OR REPLACE FUNCTION fn_najblisku_objekti( |
| | 88 | p_lon double precision, |
| | 89 | p_lat double precision, |
| | 90 | p_k integer DEFAULT 5, |
| | 91 | p_geom_tip mapa_geom_tip DEFAULT NULL |
| | 92 | ) |
| | 93 | RETURNS TABLE (mapa_objekt_id integer, tip mapa_geom_tip, rastojanie_m numeric) |
| | 94 | LANGUAGE sql |
| | 95 | STABLE |
| | 96 | AS $$ |
| | 97 | SELECT o.id, |
| | 98 | o.geom_tip, |
| | 99 | ROUND(ST_Distance( |
| | 100 | o.geom::geography, |
| | 101 | ST_SetSRID(ST_MakePoint(p_lon, p_lat), 4326)::geography |
| | 102 | )::numeric, 2) |
| | 103 | FROM or_mapa_objekt o |
| | 104 | WHERE p_geom_tip IS NULL OR o.geom_tip = p_geom_tip |
| | 105 | ORDER BY o.geom <-> ST_SetSRID(ST_MakePoint(p_lon, p_lat), 4326) |
| | 106 | LIMIT p_k; |
| | 107 | $$; |
| | 108 | }}} |
| | 109 | |
| | 110 | Функцијата `fn_najblisku_objekti` ги враќа `k`-те најблиски објекти до дадена точка (KNN — k-nearest neighbours), со опционо филтрирање по геометриски тип. Подредувањето се прави преку GiST операторот `<->` врз geometry (брзо и индексирано), додека прикажаното растојание се пресметува преку `geography` за точни метри. На обем на кампус планарното подредување се совпаѓа со геодезиското, а индексот останува искористен. Враќа множество редови, па е функција (table-valued) што се користи со `SELECT * FROM fn_najblisku_objekti(...)`. |
| | 111 | |
| | 112 | |
| | 113 | == Тригери == |
| | 114 | |
| | 115 | Тригерите гарантираат дека правилото важи без оглед кој пишува во табелата — апликација, процедура или рачен INSERT. |
| | 116 | |
| | 117 | === 1. trg_postavi_geom_tip === |
| | 118 | |
| | 119 | {{{ |
| | 120 | CREATE OR REPLACE FUNCTION trgf_postavi_geom_tip() |
| | 121 | RETURNS trigger |
| | 122 | LANGUAGE plpgsql |
| | 123 | AS $$ |
| | 124 | BEGIN |
| | 125 | NEW.geom_tip := GeometryType(NEW.geom)::mapa_geom_tip; |
| | 126 | RETURN NEW; |
| | 127 | END; |
| | 128 | $$; |
| | 129 | |
| | 130 | CREATE TRIGGER trg_postavi_geom_tip |
| | 131 | BEFORE INSERT OR UPDATE OF geom ON or_mapa_objekt |
| | 132 | FOR EACH ROW |
| | 133 | EXECUTE FUNCTION trgf_postavi_geom_tip(); |
| | 134 | }}} |
| | 135 | |
| | 136 | Тригерот `trg_postavi_geom_tip` го изведува енумот `geom_tip` од самата геометрија при секој внес или измена на `geom`. Тоа е замена за `GENERATED` колона (чиј израз не е IMMUTABLE), при што `geom_tip` секогаш останува во синхрон со податокот. Како бонус, ако геометријата е неподдржан тип (нпр. `GEOMETRYCOLLECTION`), кастот во енумот фрла грешка и внесот е одбиен. |
| | 137 | |
| | 138 | === 2. trg_geom_vo_roditel === |
| | 139 | |
| | 140 | {{{ |
| | 141 | CREATE OR REPLACE FUNCTION trgf_geom_vo_roditel() |
| | 142 | RETURNS trigger |
| | 143 | LANGUAGE plpgsql |
| | 144 | AS $$ |
| | 145 | DECLARE |
| | 146 | v_parent geometry; |
| | 147 | BEGIN |
| | 148 | IF NEW.parent_mapa_objekt_id IS NULL THEN |
| | 149 | RETURN NEW; -- врвен објект (кампус) — нема родител |
| | 150 | END IF; |
| | 151 | |
| | 152 | SELECT geom INTO v_parent |
| | 153 | FROM or_mapa_objekt |
| | 154 | WHERE id = NEW.parent_mapa_objekt_id; |
| | 155 | |
| | 156 | IF v_parent IS NULL THEN |
| | 157 | RETURN NEW; |
| | 158 | END IF; |
| | 159 | |
| | 160 | IF NOT ST_CoveredBy(NEW.geom, v_parent) THEN |
| | 161 | RAISE EXCEPTION 'Геометријата мора да лежи во родителскиот објект % (ST_CoveredBy = false)', |
| | 162 | NEW.parent_mapa_objekt_id; |
| | 163 | END IF; |
| | 164 | |
| | 165 | RETURN NEW; |
| | 166 | END; |
| | 167 | $$; |
| | 168 | |
| | 169 | CREATE TRIGGER trg_geom_vo_roditel |
| | 170 | BEFORE INSERT OR UPDATE OF geom, parent_mapa_objekt_id ON or_mapa_objekt |
| | 171 | FOR EACH ROW |
| | 172 | EXECUTE FUNCTION trgf_geom_vo_roditel(); |
| | 173 | }}} |
| | 174 | |
| | 175 | Тригерот `trg_geom_vo_roditel` го спроведува просторниот интегритет на хиерархијата: геометријата на детето мора да лежи во геометријата на родителот (`ST_CoveredBy`). Реализиран е како тригер, а не `CHECK`, бидејќи правилото гледа во '''друг ред''' (родителот), што `CHECK` не дозволува. Се користи `ST_CoveredBy` наместо `ST_Within` за да се дозволи допир по раб (нпр. просторија до надворешниот ѕид на катот). |
| | 176 | |
| | 177 | === 3. trg_bez_preklopuvanje === |
| | 178 | |
| | 179 | {{{ |
| | 180 | CREATE OR REPLACE FUNCTION trgf_bez_preklopuvanje() |
| | 181 | RETURNS trigger |
| | 182 | LANGUAGE plpgsql |
| | 183 | AS $$ |
| | 184 | BEGIN |
| | 185 | IF NEW.parent_mapa_objekt_id IS NULL THEN |
| | 186 | RETURN NEW; |
| | 187 | END IF; |
| | 188 | |
| | 189 | IF GeometryType(NEW.geom) NOT IN ('POLYGON', 'MULTIPOLYGON') THEN |
| | 190 | RETURN NEW; -- точки/линии не се проверуваат |
| | 191 | END IF; |
| | 192 | |
| | 193 | IF EXISTS ( |
| | 194 | SELECT 1 |
| | 195 | FROM or_mapa_objekt o |
| | 196 | WHERE o.parent_mapa_objekt_id = NEW.parent_mapa_objekt_id |
| | 197 | AND o.id <> NEW.id |
| | 198 | AND GeometryType(o.geom) IN ('POLYGON', 'MULTIPOLYGON') |
| | 199 | AND ST_Intersects(o.geom, NEW.geom) |
| | 200 | AND NOT ST_Touches(o.geom, NEW.geom) |
| | 201 | ) THEN |
| | 202 | RAISE EXCEPTION 'Полигонот се преклопува со постоечки соседен објект под родителот %', |
| | 203 | NEW.parent_mapa_objekt_id; |
| | 204 | END IF; |
| | 205 | |
| | 206 | RETURN NEW; |
| | 207 | END; |
| | 208 | $$; |
| | 209 | |
| | 210 | CREATE TRIGGER trg_bez_preklopuvanje |
| | 211 | BEFORE INSERT OR UPDATE OF geom, parent_mapa_objekt_id ON or_mapa_objekt |
| | 212 | FOR EACH ROW |
| | 213 | EXECUTE FUNCTION trgf_bez_preklopuvanje(); |
| | 214 | }}} |
| | 215 | |
| | 216 | Тригерот `trg_bez_preklopuvanje` спречува два полигони под ист родител да се преклопуваат. Комбинацијата `ST_Intersects AND NOT ST_Touches` го издвојува '''вистинското преклопување на внатрешности''' од дозволениот случај кога две соседни простории делат само ѕид (раб). Точките и линиите не се проверуваат. Тоа е круцијално за точни просторни распореди без преклопување на простории. |
| | 217 | |
| | 218 | |
| | 219 | == Процедура == |
| | 220 | |
| | 221 | === 1. pr_dodeli_kapacitet_od_povrshina === |
| | 222 | |
| | 223 | {{{ |
| | 224 | CREATE OR REPLACE PROCEDURE pr_dodeli_kapacitet_od_povrshina(p_m2_po_student numeric) |
| | 225 | LANGUAGE plpgsql |
| | 226 | AS $$ |
| | 227 | DECLARE |
| | 228 | r RECORD; |
| | 229 | v_count integer := 0; |
| | 230 | BEGIN |
| | 231 | IF p_m2_po_student <= 0 THEN |
| | 232 | RAISE EXCEPTION 'м² по студент мора да биде позитивно (добиено: %)', p_m2_po_student; |
| | 233 | END IF; |
| | 234 | |
| | 235 | FOR r IN |
| | 236 | SELECT o.id, ST_Area(o.geom::geography) AS povrsina |
| | 237 | FROM or_mapa_objekt o |
| | 238 | WHERE GeometryType(o.geom) IN ('POLYGON', 'MULTIPOLYGON') |
| | 239 | AND NOT EXISTS (SELECT 1 FROM or_mapa_objekt c |
| | 240 | WHERE c.parent_mapa_objekt_id = o.id) |
| | 241 | LOOP |
| | 242 | UPDATE or_mapa_objekt |
| | 243 | SET kapacitet = floor(r.povrsina / p_m2_po_student) |
| | 244 | WHERE id = r.id; |
| | 245 | v_count := v_count + 1; |
| | 246 | END LOOP; |
| | 247 | |
| | 248 | RAISE NOTICE 'Поставен капацитет за % простории.', v_count; |
| | 249 | END; |
| | 250 | $$; |
| | 251 | }}} |
| | 252 | |
| | 253 | Процедурата `pr_dodeli_kapacitet_od_povrshina` за сите „лист" полигони (простории без подобјекти) поставува `kapacitet = floor(површина_м² / m2_po_student)`. Реализирана е како процедура бидејќи е batch DML операција врз повеќе редови без повратна вредност за прашалник. Капацитет се пресметува само за крајните простории, не за катови/згради што ги содржат. |
| | 254 | |
| | 255 | |
| | 256 | == Тест податоци и верификација == |
| | 257 | |
| | 258 | За тестирање е внесена мала вгнездена хиерархија (схематски квадрати во WGS84, наменети да ја докажат логиката — содржаност, преклопување, KNN — а не реални димензии): кампус → зграда → кат → 2 простории + 1 точка на интерес. |
| | 259 | |
| | 260 | '''Преглед на објектите (изведен тип и површина):''' |
| | 261 | |
| | 262 | {{{ |
| | 263 | id | geom_tip | parent_mapa_objekt_id | m2 |
| | 264 | ----+----------+-----------------------+----------- |
| | 265 | 1 | POLYGON | | 920179.37 |
| | 266 | 2 | POLYGON | 1 | 147230.98 |
| | 267 | 3 | POLYGON | 2 | 147230.98 |
| | 268 | 4 | POLYGON | 3 | 73615.49 |
| | 269 | 5 | POLYGON | 3 | 73615.49 |
| | 270 | 6 | POINT | 2 | 0.00 |
| | 271 | }}} |
| | 272 | |
| | 273 | `geom_tip` е точно изведен (POLYGON/POINT), а `fn_povrshina_m2` враќа метри (точката има 0). |
| | 274 | |
| | 275 | '''Капацитет од површина''' (`CALL pr_dodeli_kapacitet_od_povrshina(2.0);`) — се ажурираат само листовите (простории 4 и 5): |
| | 276 | |
| | 277 | {{{ |
| | 278 | id | geom_tip | kapacitet | m2 |
| | 279 | ----+----------+-----------+---------- |
| | 280 | 4 | POLYGON | 36807 | 73615.49 |
| | 281 | 5 | POLYGON | 36807 | 73615.49 |
| | 282 | }}} |
| | 283 | |
| | 284 | '''Најблиски објекти''' од надворешна точка (`fn_najblisku_objekti(21.42, 42.02, 3)`) — растојание во метри, растечки: |
| | 285 | |
| | 286 | {{{ |
| | 287 | mapa_objekt_id | tip | rastojanie_m |
| | 288 | ----------------+---------+-------------- |
| | 289 | 1 | POLYGON | 1385.58 |
| | 290 | 2 | POLYGON | 1939.84 |
| | 291 | 5 | POLYGON | 1939.84 |
| | 292 | }}} |
| | 293 | |
| | 294 | '''GeoJSON''' (`ST_AsGeoJSON(geom)`) — доказ дека податокот е реално просторен и подготвен за рендерирање на мапа: |
| | 295 | |
| | 296 | {{{ |
| | 297 | {"type":"Point","coordinates":[21.405,42.005]} |
| | 298 | {"type":"Polygon","coordinates":[[[21.402,42.002],[21.406,42.002],...]]} |
| | 299 | }}} |
| | 300 | |
| | 301 | '''Докази дека тригерите работат:''' |
| | 302 | |
| | 303 | * Просторија со координати '''надвор''' од катот → одбиена од `trg_geom_vo_roditel`: |
| | 304 | {{{ |
| | 305 | ERROR: Геометријата мора да лежи во родителскиот објект 1 (ST_CoveredBy = false) |
| | 306 | }}} |
| | 307 | * Две простории што делат ѕид (раб) → '''дозволени'''; просторија што навлегува во соседна → одбиена од `trg_bez_preklopuvanje`: |
| | 308 | {{{ |
| | 309 | ERROR: Полигонот се преклопува со постоечки соседен објект под родителот 3 |
| | 310 | }}} |
| | 311 | * Неподдржан геометриски тип (`GEOMETRYCOLLECTION`) → одбиен од кастот во `trg_postavi_geom_tip`: |
| | 312 | {{{ |
| | 313 | ERROR: invalid input value for enum mapa_geom_tip: "GEOMETRYCOLLECTION" |
| | 314 | }}} |
| | 315 | |