Coverage for gws-app/gws/plugin/postgres/provider.py: 88%

99 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""PostgreSQL database provider.""" 

2 

3from typing import Optional 

4 

5import os 

6import re 

7 

8import gws.base.database 

9import gws.lib.crs 

10import gws.lib.extent 

11import gws.lib.net 

12import gws.lib.sa as sa 

13 

14 

15@gws.ext.config.databaseProvider('postgres') 

16class Config(gws.base.database.provider.Config): 

17 """Connection to a PostgreSQL/PostGIS database.""" 

18 

19 database: Optional[str] 

20 """Database name.""" 

21 host: Optional[str] 

22 """Database host.""" 

23 port: int = 5432 

24 """Database port.""" 

25 username: Optional[str] 

26 """User name.""" 

27 password: Optional[str] 

28 """Password.""" 

29 serviceName: Optional[str] 

30 """Service name from the PostgreSQL service file.""" 

31 options: Optional[dict] 

32 """Extra libpq connection parameters.""" 

33 

34 

35@gws.ext.object.databaseProvider('postgres') 

36class Object(gws.base.database.provider.Object): 

37 """PostgreSQL/PostGIS database provider. 

38 

39 Connects to a PostgreSQL database and describes its tables and columns, 

40 including PostGIS geometry columns, for the layers, models and finders 

41 that use it. 

42 """ 

43 

44 def url(self): 

45 return connection_url(self.config) 

46 

47 _RE_TABLE_NAME = r'''(?x)  

48 ^ 

49 ( 

50 ( " (?P<a1> ([^"] | "")+ ) " ) 

51 | 

52 (?P<a2> [^".]+ ) 

53 ) 

54 ( 

55 \. 

56 ( 

57 ( " (?P<b1> ([^"] | "")+ ) " ) 

58 | 

59 (?P<b2> [^".]+ ) 

60 ) 

61 )? 

62 $ 

63 ''' 

64 

65 _DEFAULT_SCHEMA = 'public' 

66 

67 def split_table_name(self, table_name): 

68 m = re.match(self._RE_TABLE_NAME, table_name.strip()) 

69 if not m: 

70 raise ValueError(f'invalid table name {table_name!r}') 

71 

72 d = m.groupdict() 

73 s = d['a1'] or d['a2'] 

74 t = d['b1'] or d['b2'] 

75 if not t: 

76 s, t = self._DEFAULT_SCHEMA, s 

77 

78 return s.replace('""', '"'), t.replace('""', '"') 

79 

80 def join_table_name(self, schema, name): 

81 if schema: 

82 return schema + '.' + name 

83 schema, name2 = self.split_table_name(name) 

84 return schema + '.' + name2 

85 

86 def table_bounds(self, table): 

87 desc = self.describe(table) 

88 if not desc.geometryName: 

89 return 

90 

91 tab = self.table(table) 

92 sql = sa.select(sa.func.ST_Extent(tab.columns.get(desc.geometryName))) 

93 with self.connect() as conn: 

94 box = conn.execute(sql).scalar_one() 

95 extent = gws.lib.extent.from_box(box) 

96 if extent: 

97 return gws.Bounds(extent=extent, crs=gws.lib.crs.get(desc.geometrySrid)) 

98 

99 def describe_column(self, table, column_name): 

100 col = super().describe_column(table, column_name) 

101 

102 if col.nativeType == 'ARRAY': 

103 sa_col = self.column(table, column_name) 

104 it = getattr(sa_col.type, 'item_type', None) 

105 ia = self.SA_TO_ATTR.get(type(it).__name__.upper()) 

106 if ia == gws.AttributeType.str: 

107 col.type = gws.AttributeType.strlist 

108 elif ia == gws.AttributeType.int: 

109 col.type = gws.AttributeType.intlist 

110 elif ia == gws.AttributeType.float: 

111 col.type = gws.AttributeType.floatlist 

112 else: 

113 col.type = self.UNKNOWN_ARRAY_TYPE 

114 return col 

115 

116 if col.nativeType == 'GEOMETRY': 

117 typ, srid = self._get_geom_type_and_srid(table, column_name) 

118 col.type = gws.AttributeType.geometry 

119 col.geometryType = self.SA_TO_GEOM.get(typ, gws.GeometryType.geometry) 

120 col.geometrySrid = srid 

121 return col 

122 

123 return col 

124 

125 def _get_geom_type_and_srid(self, table, column_name): 

126 """Return the geometry type and SRID of a column, from its type or from ``geometry_columns``.""" 

127 sa_table = self.table(table) 

128 sa_col = self.column(table, column_name) 

129 

130 typ = getattr(sa_col.type, 'geometry_type', '').upper() 

131 srid = getattr(sa_col.type, 'srid', 0) 

132 

133 if typ != 'GEOMETRY' and srid > 0: 

134 return typ, srid 

135 

136 # not a typmod, possibly constraint-based. Query "geometry_columns"... 

137 

138 gcs = getattr(self, '_geometry_columns_cache', None) 

139 if not gcs: 

140 gcs = self.select_text(f''' 

141 SELECT  

142 f_table_schema, 

143 f_table_name, 

144 f_geometry_column, 

145 type, 

146 srid 

147 FROM public.geometry_columns 

148 ''') 

149 setattr(self, '_geometry_columns_cache', gcs) 

150 

151 for gc in gcs: 

152 if ( 

153 gc['f_table_schema'] == sa_table.schema 

154 and gc['f_table_name'] == sa_table.name 

155 and gc['f_geometry_column'] == sa_col.name 

156 ): 

157 return gc['type'], gc['srid'] 

158 

159 return 'GEOMETRY', -1 

160 

161 

162## 

163 

164def connection_url(cfg: gws.Config) -> Optional[str]: 

165 """Create a connection URL from a provider configuration. 

166 

167 If ``host`` is configured, the URL is built from the host, port, database 

168 and credentials. Otherwise, if ``serviceName`` is configured, the URL refers 

169 to that service in the PostgreSQL service file given by ``PGSERVICEFILE``. 

170 ``options`` are added as URL parameters, with ``application_name`` set to 

171 ``GWS`` by default. 

172 

173 Args: 

174 cfg: Provider configuration. 

175 

176 Returns: 

177 The connection URL, or ``None`` if neither ``host`` nor ``serviceName`` is configured. 

178 

179 Raises: 

180 ``sa.Error``: If ``serviceName`` is used and the service file does not exist. 

181 """ 

182 # https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING 

183 # https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS 

184 

185 defaults = { 

186 'application_name': 'GWS', 

187 } 

188 

189 params = gws.u.merge(defaults, cfg.get('options')) 

190 

191 p = cfg.get('host') 

192 if p: 

193 return gws.lib.net.make_url( 

194 scheme='postgresql', 

195 username=cfg.get('username'), 

196 password=cfg.get('password'), 

197 hostname=p, 

198 port=cfg.get('port'), 

199 path=cfg.get('database') or cfg.get('dbname') or '', 

200 params=params, 

201 ) 

202 

203 p = cfg.get('serviceName') 

204 if p: 

205 s = os.getenv('PGSERVICEFILE') 

206 if not s or not os.path.isfile(s): 

207 raise sa.Error(f'PGSERVICEFILE {s!r} not found') 

208 

209 params['service'] = p 

210 

211 return gws.lib.net.make_url( 

212 scheme='postgresql', 

213 hostname='', 

214 path=cfg.get('database') or cfg.get('dbname') or '', 

215 params=params, 

216 )