1 |
# Copyright (C) 2003 by Intevation GmbH |
2 |
# Authors: |
3 |
# Martin Mueller <[email protected]> |
4 |
# Bernhard Herzog <[email protected]> |
5 |
# |
6 |
# This program is free software under the GPL (>=v2) |
7 |
# Read the file COPYING coming with the software for details. |
8 |
|
9 |
"""Basic interface to a PostGIS database""" |
10 |
|
11 |
from __future__ import generators |
12 |
|
13 |
try: |
14 |
import psycopg |
15 |
except ImportError: |
16 |
psycopg = None |
17 |
|
18 |
import table |
19 |
import wellknowntext |
20 |
|
21 |
from data import SHAPETYPE_POLYGON, SHAPETYPE_ARC, SHAPETYPE_POINT, RAW_WKT |
22 |
|
23 |
def has_postgis_support(): |
24 |
"""Return whether this Thuban instance supports PostGIS connections |
25 |
|
26 |
Having PostGIS support means that the psycopg module can be |
27 |
imported. |
28 |
""" |
29 |
return psycopg is not None |
30 |
|
31 |
def psycopg_version(): |
32 |
return psycopg.__version__ |
33 |
|
34 |
if psycopg is not None: |
35 |
type_map = [(psycopg.STRING, table.FIELDTYPE_STRING), |
36 |
(psycopg.INTEGER, table.FIELDTYPE_INT), |
37 |
(psycopg.FLOAT, table.FIELDTYPE_DOUBLE)] |
38 |
|
39 |
class PostGISConnection: |
40 |
|
41 |
"""Represent a PostGIS database |
42 |
|
43 |
A PostGISConnection instance has the following public attributes: |
44 |
|
45 |
dbname -- The name of the database |
46 |
host, port -- Host and port to connect to |
47 |
user -- The user name to connect as. |
48 |
|
49 |
All of these attributes are strings and may be empty strings to |
50 |
indicate default values. |
51 |
""" |
52 |
|
53 |
def __init__(self, dbname, host="", user="", password="", dbtype="", |
54 |
port=""): |
55 |
self.dbname = dbname |
56 |
self.host = host |
57 |
self.port = port |
58 |
self.user = user |
59 |
self.password = password |
60 |
self.dbtype = dbtype |
61 |
self.connect() |
62 |
|
63 |
def connect(self): |
64 |
"""Internal: Establish the database connection""" |
65 |
params = [] |
66 |
for name in ("host", "port", "dbname", "user", "password"): |
67 |
val = getattr(self, name) |
68 |
if val: |
69 |
params.append("%s=%s" % (name, val)) |
70 |
self.connection = psycopg.connect(" ".join(params)) |
71 |
|
72 |
# determine the OID for the geometry type. This is PostGIS |
73 |
# specific. |
74 |
cursor = self.connection.cursor() |
75 |
cursor.execute("SELECT OID, typname FROM pg_type WHERE" |
76 |
+" typname = 'geometry'") |
77 |
row = cursor.fetchone() |
78 |
self.connection.commit() |
79 |
if row is not None: |
80 |
self.geometry_type = row[0] |
81 |
else: |
82 |
raise ValueError("Can't determine postgres type of geometries") |
83 |
|
84 |
def BriefDescription(self): |
85 |
"""Return a brief, one-line description of the connection |
86 |
|
87 |
The return value is suitable for a list box of all database |
88 |
connections. |
89 |
""" |
90 |
return ("postgis://%(user)s@%(host)s:%(port)s/%(dbname)s" |
91 |
% self.__dict__) |
92 |
|
93 |
def Close(self): |
94 |
"""Close the database connection""" |
95 |
self.connection.close() |
96 |
|
97 |
def GeometryTables(self): |
98 |
"""Return a list with the names of all tables with a geometry column""" |
99 |
cursor = self.connection.cursor() |
100 |
cursor.execute("SELECT f_table_name FROM geometry_columns;") |
101 |
result = [row[0] for row in cursor.fetchall()] |
102 |
self.connection.commit() |
103 |
return result |
104 |
|
105 |
def cursor(self): |
106 |
"""Return a DB API 2.0 cursor for the database""" |
107 |
return self.connection.cursor() |
108 |
|
109 |
|
110 |
|
111 |
class PostGISColumn: |
112 |
|
113 |
"""Column description for a PostGISTable""" |
114 |
|
115 |
def __init__(self, name, type, index): |
116 |
self.name = name |
117 |
self.type = type |
118 |
self.index = index |
119 |
|
120 |
|
121 |
class PostGISTable: |
122 |
|
123 |
"""A Table in a PostGIS database""" |
124 |
|
125 |
def __init__(self, db, tablename): |
126 |
"""Initialize the PostGISTable. |
127 |
|
128 |
The db parameter should be an instance of PostGISConnection and |
129 |
tablename the name of a table in the database represented by db. |
130 |
""" |
131 |
self.db = db |
132 |
self.tablename = tablename |
133 |
self.column_map = {} |
134 |
self._fetch_table_information() |
135 |
|
136 |
def _fetch_table_information(self): |
137 |
"""Internal: Update information about the table""" |
138 |
self.columns = [] |
139 |
cursor = self.db.cursor() |
140 |
cursor.execute("SELECT * FROM %s WHERE 0=1" % self.tablename) |
141 |
description = cursor.description |
142 |
|
143 |
for i in range(len(description)): |
144 |
for pgtyp, tabletyp in type_map: |
145 |
if pgtyp == description[i][1]: |
146 |
col = PostGISColumn(description[i][0], tabletyp, i) |
147 |
break |
148 |
else: |
149 |
if description[i][1] == self.db.geometry_type: |
150 |
self.geometry_column = description[i][0] |
151 |
# No matching table type. Ignore the column. |
152 |
# FIXME: We should at least print a warning about |
153 |
# ignored columns |
154 |
continue |
155 |
self.columns.append(col) |
156 |
|
157 |
for col in self.columns: |
158 |
self.column_map[col.name] = col |
159 |
self.column_map[col.index] = col |
160 |
|
161 |
# Build query string for ReadRowAsDict |
162 |
self.query_stmt = ("SELECT %s from %s" |
163 |
% (", ".join([col.name for col in self.columns]), |
164 |
self.tablename)) |
165 |
|
166 |
def Dependencies(self): |
167 |
"""Return an empty tuple because a PostGISTable depends on nothing else |
168 |
""" |
169 |
return () |
170 |
|
171 |
def NumColumns(self): |
172 |
return len(self.columns) |
173 |
|
174 |
def Columns(self): |
175 |
return self.columns |
176 |
|
177 |
def Column(self, col): |
178 |
return self.column_map[col] |
179 |
|
180 |
def HasColumn(self, col): |
181 |
return self.column_map.has_key(col) |
182 |
|
183 |
def NumRows(self): |
184 |
cursor = self.db.cursor() |
185 |
cursor.execute("SELECT count(*) FROM %s" % self.tablename) |
186 |
return cursor.fetchone()[0] |
187 |
|
188 |
def ReadRowAsDict(self, row): |
189 |
cursor = self.db.cursor() |
190 |
cursor.execute(self.query_stmt + " LIMIT 1 OFFSET %d" % row) |
191 |
result = {} |
192 |
for col, value in zip(self.columns, cursor.fetchone()): |
193 |
result[col.name] = value |
194 |
return result |
195 |
|
196 |
def ReadValue(self, row, col): |
197 |
cursor = self.db.cursor() |
198 |
cursor.execute("SELECT %s FROM %s LIMIT 1 OFFSET %d" % |
199 |
(self.column_map[col].name, self.tablename, row)) |
200 |
return cursor.fetchone()[0] |
201 |
|
202 |
def ValueRange(self, col): |
203 |
cursor = self.db.cursor() |
204 |
name = self.column_map[col].name |
205 |
cursor.execute("SELECT min(%s), max(%s) FROM %s" % |
206 |
(name, name, self.tablename)) |
207 |
return tuple(cursor.fetchone()) |
208 |
|
209 |
def UniqueValues(self, col): |
210 |
cursor = self.db.cursor() |
211 |
name = self.column_map[col].name |
212 |
cursor.execute("SELECT %s FROM %s GROUP BY %s" % |
213 |
(name, self.tablename, name)) |
214 |
return [row[0] for row in cursor.fetchall()] |
215 |
|
216 |
def SimpleQuery(self, left, comparison, right): |
217 |
if comparison not in ("==", "!=", "<", "<=", ">=", ">"): |
218 |
raise ValueError("Comparison operator %r not allowed" % comparison) |
219 |
|
220 |
if comparison == "==": |
221 |
comparison = "=" |
222 |
|
223 |
if isinstance(right, PostGISColumn): |
224 |
right_template = right.name |
225 |
params = () |
226 |
else: |
227 |
right_template = "%s" |
228 |
params = (right,) |
229 |
|
230 |
query = "SELECT gid FROM %s WHERE %s %s %s ORDER BY gid;" \ |
231 |
% (self.tablename, left.name, comparison, right_template) |
232 |
|
233 |
cursor = self.db.cursor() |
234 |
cursor.execute(query, params) |
235 |
result = [] |
236 |
while 1: |
237 |
row = cursor.fetchone() |
238 |
if row is None: |
239 |
break |
240 |
result.append(row[0]) |
241 |
return result |
242 |
|
243 |
|
244 |
class PostGISShape: |
245 |
|
246 |
def __init__(self, shapeid, data): |
247 |
self.shapeid = shapeid |
248 |
self.data = data |
249 |
|
250 |
def compute_bbox(self): |
251 |
""" |
252 |
Return the bounding box of the shape as a tuple (minx,miny,maxx,maxy) |
253 |
""" |
254 |
xs = [] |
255 |
ys = [] |
256 |
for part in self.Points(): |
257 |
for x, y in part: |
258 |
xs.append(x) |
259 |
ys.append(y) |
260 |
return (min(xs), min(ys), max(xs), max(ys)) |
261 |
|
262 |
def ShapeID(self): |
263 |
return self.shapeid |
264 |
|
265 |
def Points(self): |
266 |
return wellknowntext.parse_wkt_thuban(self.data) |
267 |
|
268 |
def RawData(self): |
269 |
return self.data |
270 |
|
271 |
|
272 |
shapetype_map = {"POLYGON": SHAPETYPE_POLYGON, |
273 |
"MULTILINESTRING": SHAPETYPE_ARC, |
274 |
"POINT": SHAPETYPE_POINT} |
275 |
|
276 |
|
277 |
class PostGISShapeStore(PostGISTable): |
278 |
|
279 |
"""Shapestore interface to a table in a PostGIS database""" |
280 |
|
281 |
def Table(self): |
282 |
"""Return self since a PostGISShapeStore is its own table.""" |
283 |
return self |
284 |
|
285 |
def OrigShapeStore(self): |
286 |
"""Return None since the PostGISShapeStore is not derived from another |
287 |
""" |
288 |
return None |
289 |
|
290 |
def ShapeType(self): |
291 |
"""Return the type of the shapes in the shapestore.""" |
292 |
cursor = self.db.cursor() |
293 |
cursor.execute("SELECT type FROM geometry_columns WHERE" |
294 |
" f_table_name=%s", (self.tablename,)) |
295 |
result = cursor.fetchone()[0] |
296 |
cursor.close() |
297 |
return shapetype_map[result] |
298 |
|
299 |
def RawShapeFormat(self): |
300 |
"""Return the raw data format of the shape data. |
301 |
|
302 |
For the PostGISShapeStore this is RAW_WKT. |
303 |
""" |
304 |
return RAW_WKT |
305 |
|
306 |
def NumShapes(self): |
307 |
# The number of shapes is the same as the number of rows, |
308 |
# assuming that the geometry can't be NULL. |
309 |
return self.NumRows() |
310 |
|
311 |
def BoundingBox(self): |
312 |
"""Return the bounding box of all shapes in the postgis table""" |
313 |
minx = miny = maxx = maxy = None |
314 |
x=[] |
315 |
y=[] |
316 |
cursor = self.db.cursor() |
317 |
try: |
318 |
stmt = ("SELECT AsText(Envelope(%s)) FROM %s;" |
319 |
% (self.geometry_column, self.tablename)) |
320 |
cursor.execute(stmt) |
321 |
result = cursor.fetchone() |
322 |
while result: |
323 |
result = result[0] |
324 |
# Here we must do some parsing through the result string |
325 |
# to get the points out of the polygon which representes |
326 |
# the bounding box The first and the last point of a |
327 |
# polygon are identical |
328 |
result = result.split("(")[2] |
329 |
result = result.split(")")[0] |
330 |
points = result.split(",") |
331 |
del points[4] # Remove the last point |
332 |
for point in points: |
333 |
px, py = point.split() |
334 |
x.append(float(px)) |
335 |
y.append(float(py)) |
336 |
result = cursor.fetchone() |
337 |
finally: |
338 |
cursor.close() |
339 |
if not x: |
340 |
# Empty table |
341 |
return None |
342 |
return (min(x), min(y), max(x), max(y)) |
343 |
|
344 |
def Shape(self, shapeid): |
345 |
cursor = self.db.cursor() |
346 |
cursor.execute("SELECT AsText(%s) FROM %s WHERE gid=%d" |
347 |
% (self.geometry_column, self.tablename, shapeid)) |
348 |
wkt = cursor.fetchone()[0] |
349 |
cursor.close() |
350 |
return PostGISShape(shapeid, wkt) |
351 |
|
352 |
def ShapesInRegion(self, bbox): |
353 |
"""Generate all shapes overlapping the region given by bbox.""" |
354 |
# IMPORTANT:This will work for PostGIS < 0.8 |
355 |
left, bottom, right, top = bbox |
356 |
geom = (("POLYGON((" + ", ".join(["%f %f"] * 5) + "))") |
357 |
% (left, bottom, left, top, right, top, right, bottom, |
358 |
left, bottom)) |
359 |
cursor = self.db.cursor() |
360 |
cursor.execute("SELECT gid, AsText(%s) FROM %s" |
361 |
" WHERE %s && GeometryFromText('%s', -1) ORDER BY gid" |
362 |
% (self.geometry_column, self.tablename, |
363 |
self.geometry_column, geom)) |
364 |
while 1: |
365 |
result = cursor.fetchone() |
366 |
if result is None: |
367 |
raise StopIteration |
368 |
yield PostGISShape(result[0], result[1]) |