• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MerginMaps / geodiff / 3710958510

pending completion
3710958510

push

github

GitHub
allow python logger to set to None (#192)

3047 of 4066 relevant lines covered (74.94%)

409.27 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.32
/geodiff/src/drivers/postgresdriver.cpp
1
/*
2
 GEODIFF - MIT License
3
 Copyright (C) 2020 Martin Dobias
4
*/
5

6
#include "postgresdriver.h"
7

8
#include "geodifflogger.hpp"
9
#include "geodiffutils.hpp"
10
#include "changeset.h"
11
#include "changesetreader.h"
12
#include "changesetutils.h"
13
#include "changesetwriter.h"
14
#include "postgresutils.h"
15
#include "sqliteutils.h"
16
#include "geodiffcontext.hpp"
17

18
#include <algorithm>
19
#include <iostream>
20
#include <memory.h>
21

22

23
/**
24
 * Wrapper around PostgreSQL transactions.
25
 *
26
 * Constructor start a trasaction, it needs to be confirmed by a call to commitChanges() when
27
 * changes are ready to be written. If commitChanges() is not called, changes since the constructor
28
 * will be rolled back (so that on exception everything gets cleaned up properly).
29
 */
30
class PostgresTransaction
31
{
32
  public:
33
    explicit PostgresTransaction( PGconn *conn )
24✔
34
      : mConn( conn )
24✔
35
    {
36
      PostgresResult res( execSql( mConn, "BEGIN" ) );
48✔
37
      if ( res.status() != PGRES_COMMAND_OK )
24✔
38
        throw GeoDiffException( "Unable to start transaction" );
×
39
    }
24✔
40

41
    ~PostgresTransaction()
24✔
42
    {
43
      if ( mConn )
24✔
44
      {
45
        // we had some problems - roll back any pending changes
46
        PostgresResult res( execSql( mConn, "ROLLBACK" ) );
2✔
47
      }
1✔
48
    }
24✔
49

50
    void commitChanges()
23✔
51
    {
52
      assert( mConn );
23✔
53
      PostgresResult res( execSql( mConn, "COMMIT" ) );
46✔
54
      if ( res.status() != PGRES_COMMAND_OK )
23✔
55
        throw GeoDiffException( "Unable to commit transaction" );
×
56

57
      // reset handler to the database so that the destructor does nothing
58
      mConn = nullptr;
23✔
59
    }
23✔
60

61
  private:
62
    PGconn *mConn = nullptr;
63
};
64

65
/////
66

67
void PostgresDriver::logApplyConflict( const std::string &type, const ChangesetEntry &entry ) const
×
68
{
69
  context()->logger().warn( "CONFLICT: " + type + ":\n" + changesetEntryToJSON( entry ).dump( 2 ) );
×
70
}
×
71

72
PostgresDriver::PostgresDriver( const Context *context )
63✔
73
  : Driver( context )
63✔
74
{
75
}
63✔
76

77
PostgresDriver::~PostgresDriver()
126✔
78
{
79
  close();
63✔
80
}
126✔
81

82
void PostgresDriver::openPrivate( const DriverParametersMap &conn )
63✔
83
{
84
  DriverParametersMap::const_iterator connInfo = conn.find( "conninfo" );
63✔
85
  if ( connInfo == conn.end() )
63✔
86
    throw GeoDiffException( "Missing 'conninfo' parameter" );
×
87
  std::string connInfoStr = connInfo->second;
63✔
88

89
  DriverParametersMap::const_iterator baseSchema = conn.find( "base" );
63✔
90
  if ( baseSchema == conn.end() )
63✔
91
    throw GeoDiffException( "Missing 'base' parameter" );
×
92
  mBaseSchema = baseSchema->second;
63✔
93

94
  DriverParametersMap::const_iterator modifiedSchema = conn.find( "modified" );
63✔
95
  mModifiedSchema = ( modifiedSchema == conn.end() ) ? std::string() : modifiedSchema->second;
63✔
96

97
  if ( mConn )
63✔
98
    throw GeoDiffException( "Connection already opened" );
×
99

100
  PGconn *c = PQconnectdb( connInfoStr.c_str() );
63✔
101

102
  if ( PQstatus( c ) != CONNECTION_OK )
63✔
103
  {
104
    throw GeoDiffException( "Cannot connect to PostgreSQL database: " + std::string( PQerrorMessage( c ) ) );
×
105
  }
106

107
  mConn = c;
63✔
108

109
  // Make sure we are using enough digits for floating point numbers to make sure that we are
110
  // not loosing any digits when querying data.
111
  // https://www.postgresql.org/docs/12/runtime-config-client.html#GUC-EXTRA-FLOAT-DIGITS
112
  PostgresResult res( execSql( mConn, "SET extra_float_digits = 2;" ) );
126✔
113
  if ( res.status() != PGRES_COMMAND_OK )
63✔
114
    throw GeoDiffException( "Failed to set extra_float_digits" );
×
115
}
63✔
116

117
void PostgresDriver::close()
63✔
118
{
119
  mBaseSchema.clear();
63✔
120
  mModifiedSchema.clear();
63✔
121
  if ( mConn )
63✔
122
    PQfinish( mConn );
63✔
123
  mConn = nullptr;
63✔
124
}
63✔
125

126
void PostgresDriver::open( const DriverParametersMap &conn )
47✔
127
{
128
  openPrivate( conn );
47✔
129

130
  {
131
    PostgresResult resBase( execSql( mConn, "SELECT 1 FROM pg_namespace WHERE nspname = " + quotedString( mBaseSchema ) ) );
94✔
132
    if ( resBase.rowCount() == 0 )
47✔
133
    {
134
      std::string baseSchema = mBaseSchema;  // close() will erase mBaseSchema...
×
135
      close();
×
136
      throw GeoDiffException( "The base schema does not exist: " + baseSchema );
×
137
    }
×
138
  }
47✔
139

140
  if ( !mModifiedSchema.empty() )
47✔
141
  {
142
    PostgresResult resBase( execSql( mConn, "SELECT 1 FROM pg_namespace WHERE nspname = " + quotedString( mModifiedSchema ) ) );
32✔
143
    if ( resBase.rowCount() == 0 )
16✔
144
    {
145
      std::string modifiedSchema = mModifiedSchema;  // close() will erase mModifiedSchema...
×
146
      close();
×
147
      throw GeoDiffException( "The base schema does not exist: " + modifiedSchema );
×
148
    }
×
149
  }
16✔
150
}
47✔
151

152
void PostgresDriver::create( const DriverParametersMap &conn, bool overwrite )
16✔
153
{
154
  openPrivate( conn );
16✔
155

156
  std::string sql;
16✔
157
  if ( overwrite )
16✔
158
    sql += "DROP SCHEMA IF EXISTS " + quotedIdentifier( mBaseSchema ) + " CASCADE; ";
15✔
159
  sql += "CREATE SCHEMA " + quotedIdentifier( mBaseSchema ) + ";";
16✔
160

161
  PostgresResult res( execSql( mConn, sql ) );
16✔
162
  if ( res.status() != PGRES_COMMAND_OK )
16✔
163
    throw GeoDiffException( "Failure creating table: " + res.statusErrorMessage() );
×
164
}
16✔
165

166

167
std::vector<std::string> PostgresDriver::listTables( bool useModified )
69✔
168
{
169
  if ( !mConn )
69✔
170
    throw GeoDiffException( "Not connected to a database" );
×
171
  if ( useModified && mModifiedSchema.empty() )
69✔
172
    throw GeoDiffException( "Should use modified schema, but it was not set" );
×
173

174
  std::string schemaName = useModified ? mModifiedSchema : mBaseSchema;
69✔
175
  std::string sql = "select tablename from pg_tables where schemaname=" + quotedString( schemaName );
69✔
176
  PostgresResult res( execSql( mConn, sql ) );
69✔
177

178
  std::vector<std::string> tables;
69✔
179
  for ( int i = 0; i < res.rowCount(); ++i )
169✔
180
  {
181
    if ( startsWith( res.value( i, 0 ), "gpkg_" ) )
100✔
182
      continue;
×
183

184
    if ( context()->isTableSkipped( res.value( i, 0 ) ) )
100✔
185
      continue;
×
186

187
    tables.push_back( res.value( i, 0 ) );
100✔
188
  }
189

190
  // make sure tables are in alphabetical order, so that if we compare table names
191
  // from different schemas, they should be matching
192
  std::sort( tables.begin(), tables.end() );
69✔
193

194
  return tables;
138✔
195
}
69✔
196

197
struct GeometryTypeDetails
198
{
199
  // cppcheck-suppress unusedStructMember
200
  const char *flatType;
201
  // cppcheck-suppress unusedStructMember
202
  bool hasZ;
203
  // cppcheck-suppress unusedStructMember
204
  bool hasM;
205
};
206

207
static void extractGeometryTypeDetails( const std::string &geomType, const std::string &coordinateDimension, std::string &flatGeomType, bool &hasZ, bool &hasM )
135✔
208
{
209
  std::map<std::string, GeometryTypeDetails> d =
210
  {
211
    { "POINT",   { "POINT", false, false } },
212
    { "POINTZ",  { "POINT", true,  false } },
213
    { "POINTM",  { "POINT", false, true  } },
214
    { "POINTZM", { "POINT", true,  true  } },
215
    { "LINESTRING",   { "LINESTRING", false, false } },
216
    { "LINESTRINGZ",  { "LINESTRING", true,  false } },
217
    { "LINESTRINGM",  { "LINESTRING", false, true  } },
218
    { "LINESTRINGZM", { "LINESTRING", true,  true  } },
219
    { "POLYGON",   { "POLYGON", false, false } },
220
    { "POLYGONZ",  { "POLYGON", true,  false } },
221
    { "POLYGONM",  { "POLYGON", false, true  } },
222
    { "POLYGONZM", { "POLYGON", true,  true  } },
223

224
    { "MULTIPOINT",   { "MULTIPOINT", false, false } },
225
    { "MULTIPOINTZ",  { "MULTIPOINT", true,  false } },
226
    { "MULTIPOINTM",  { "MULTIPOINT", false, true  } },
227
    { "MULTIPOINTZM", { "MULTIPOINT", true,  true  } },
228
    { "MULTILINESTRING",   { "MULTILINESTRING", false, false } },
229
    { "MULTILINESTRINGZ",  { "MULTILINESTRING", true,  false } },
230
    { "MULTILINESTRINGM",  { "MULTILINESTRING", false, true  } },
231
    { "MULTILINESTRINGZM", { "MULTILINESTRING", true,  true  } },
232
    { "MULTIPOLYGON",   { "MULTIPOLYGON", false, false } },
233
    { "MULTIPOLYGONZ",  { "MULTIPOLYGON", true,  false } },
234
    { "MULTIPOLYGONM",  { "MULTIPOLYGON", false, true  } },
235
    { "MULTIPOLYGONZM", { "MULTIPOLYGON", true,  true  } },
236

237
    { "GEOMETRYCOLLECTION",   { "GEOMETRYCOLLECTION", false, false } },
238
    { "GEOMETRYCOLLECTIONZ",  { "GEOMETRYCOLLECTION", true,  false } },
239
    { "GEOMETRYCOLLECTIONM",  { "GEOMETRYCOLLECTION", false, true  } },
240
    { "GEOMETRYCOLLECTIONZM", { "GEOMETRYCOLLECTION", true,  true  } },
241

242
    // TODO: curve geometries
243
  };
4,050✔
244

245
  /*
246
   *  Special PostGIS coding of xyZ, xyM and xyZM type https://postgis.net/docs/using_postgis_dbmanagement.html#geometry_columns
247
   *  coordinateDimension number bears information about third and fourth dimension.
248
   */
249
  std::string type = geomType;
135✔
250

251
  if ( coordinateDimension == "4" )
135✔
252
  {
253
    type += "ZM";
18✔
254
  }
255
  else if ( ( coordinateDimension == "3" ) && ( type.back() != 'M' ) )
117✔
256
  {
257
    type += "Z";
18✔
258
  }
259

260
  auto it = d.find( type );
135✔
261
  if ( it != d.end() )
135✔
262
  {
263
    flatGeomType = it->second.flatType;
135✔
264
    hasZ = it->second.hasZ;
135✔
265
    hasM = it->second.hasM;
135✔
266
  }
267
  else
268
    throw GeoDiffException( "Unknown geometry type: " + type );
×
269
}
135✔
270

271

272
TableSchema PostgresDriver::tableSchema( const std::string &tableName, bool useModified )
145✔
273
{
274
  if ( !mConn )
145✔
275
    throw GeoDiffException( "Not connected to a database" );
×
276
  if ( useModified && mModifiedSchema.empty() )
145✔
277
    throw GeoDiffException( "Should use modified schema, but it was not set" );
×
278

279
  std::string schemaName = useModified ? mModifiedSchema : mBaseSchema;
145✔
280

281
  // try to figure out details of the geometry columns (if any)
282
  std::string sqlGeomDetails = "SELECT f_geometry_column, type, srid, coord_dimension FROM geometry_columns WHERE f_table_schema = " +
290✔
283
                               quotedString( schemaName ) + " AND f_table_name = " + quotedString( tableName );
580✔
284
  std::map<std::string, std::pair<std::string, std::string>> geomTypes;
145✔
285
  std::map<std::string, int> geomSrids;
145✔
286
  PostgresResult resGeomDetails( execSql( mConn, sqlGeomDetails ) );
145✔
287
  for ( int i = 0; i < resGeomDetails.rowCount(); ++i )
280✔
288
  {
289
    std::string name = resGeomDetails.value( i, 0 );
135✔
290
    std::string type = resGeomDetails.value( i, 1 );
135✔
291
    std::string srid = resGeomDetails.value( i, 2 );
135✔
292
    std::string dimension = resGeomDetails.value( i, 3 );
135✔
293
    int sridInt = srid.empty() ? -1 : atoi( srid.c_str() );
135✔
294
    geomTypes[name] = { type, dimension };
135✔
295
    geomSrids[name] = sridInt;
135✔
296
  }
135✔
297

298
  std::string sqlColumns =
299
    "SELECT a.attname, pg_catalog.format_type(a.atttypid, a.atttypmod), i.indisprimary, a.attnotnull, "
300
    "    EXISTS ("
301
    "             SELECT FROM pg_attrdef ad"
302
    "             WHERE  ad.adrelid = a.attrelid"
303
    "             AND    ad.adnum   = a.attnum"
304
    "             AND    pg_get_expr(ad.adbin, ad.adrelid)"
305
    "                  = 'nextval('''"
306
    "                 || (pg_get_serial_sequence (a.attrelid::regclass::text, a.attname))::regclass"
307
    "                 || '''::regclass)'"
308
    "       ) AS has_sequence"
309
    " FROM pg_catalog.pg_attribute a"
310
    " LEFT JOIN pg_index i ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)"
311
    " WHERE"
312
    "   a.attnum > 0"
313
    "   AND NOT a.attisdropped"
314
    "   AND a.attrelid = ("
315
    "      SELECT c.oid"
316
    "        FROM pg_catalog.pg_class c"
317
    "        LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace"
318
    "        WHERE c.relname = " + quotedString( tableName ) +
290✔
319
    "          AND n.nspname = " + quotedString( schemaName ) +
435✔
320
    "   )"
321
    "  ORDER BY a.attnum";
145✔
322

323
  PostgresResult res( execSql( mConn, sqlColumns ) );
145✔
324

325
  int srsId = -1;
145✔
326
  TableSchema schema;
145✔
327
  schema.name = tableName;
145✔
328
  for ( int i = 0; i < res.rowCount(); ++i )
755✔
329
  {
330
    TableColumnInfo col;
610✔
331
    col.name = res.value( i, 0 );
610✔
332
    col.isPrimaryKey = ( res.value( i, 2 ) == "t" );
610✔
333
    std::string type( res.value( i, 1 ) );
610✔
334

335
    if ( startsWith( type, "geometry" ) )
610✔
336
    {
337
      std::string geomTypeName;
135✔
338
      bool hasM = false;
135✔
339
      bool hasZ = false;
135✔
340

341
      if ( geomTypes.find( col.name ) != geomTypes.end() )
135✔
342
      {
343
        extractGeometryTypeDetails( geomTypes[col.name].first, geomTypes[col.name].second, geomTypeName, hasZ, hasM );
135✔
344
        srsId = geomSrids[col.name];
135✔
345
      }
346
      col.setGeometry( geomTypeName, srsId, hasM, hasZ );
135✔
347
    }
135✔
348

349
    col.type = columnType( context(), type, Driver::POSTGRESDRIVERNAME, col.isGeometry );
610✔
350
    col.isNotNull = ( res.value( i, 3 ) == "t" );
610✔
351
    col.isAutoIncrement = ( res.value( i, 4 ) == "t" );
610✔
352

353
    schema.columns.push_back( col );
610✔
354
  }
610✔
355

356
  //
357
  // get CRS details
358
  //
359

360
  if ( srsId != -1 )
145✔
361
  {
362
    PostgresResult resCrs( execSql( mConn,
363
                                    "SELECT auth_name, auth_srid, srtext "
364
                                    "FROM spatial_ref_sys WHERE srid = " + std::to_string( srsId ) ) );
270✔
365

366
    if ( resCrs.rowCount() == 0 )
135✔
367
      throw GeoDiffException( "Unknown CRS in table " + tableName );
×
368
    schema.crs.srsId = srsId;
135✔
369
    schema.crs.authName = resCrs.value( 0, 0 );
135✔
370
    schema.crs.authCode = atoi( resCrs.value( 0, 1 ).c_str() );
135✔
371
    schema.crs.wkt = resCrs.value( 0, 2 );
135✔
372
  }
135✔
373

374
  return schema;
290✔
375
}
145✔
376

377

378
static std::string allColumnNames( const TableSchema &tbl, const std::string &prefix = "" )
104✔
379
{
380
  std::string columns;
104✔
381
  for ( const TableColumnInfo &c : tbl.columns )
530✔
382
  {
383
    if ( !columns.empty() )
426✔
384
      columns += ", ";
322✔
385

386
    std::string name;
426✔
387
    if ( !prefix.empty() )
426✔
388
      name = prefix + ".";
158✔
389
    name += quotedIdentifier( c.name );
426✔
390

391
    if ( c.isGeometry )
426✔
392
      columns += "ST_AsBinary(" + name + ")";
95✔
393
    else if ( c.type == "timestamp without time zone" )
331✔
394
    {
395
      // by default postgresql would return date/time as a formatted string
396
      // e.g. "2020-07-13 16:17:54" but we want IS0-8601 format "2020-07-13T16:17:54.60Z"
397
      columns += "to_char(" + name + ",'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')";
10✔
398
    }
399
    else
400
      columns += name;
321✔
401
  }
426✔
402
  return columns;
104✔
403
}
×
404

405

406
//! Constructs SQL query to get all rows that do not exist in the other table (used for insert and delete)
407
static std::string sqlFindInserted( const std::string &schemaNameBase, const std::string &schemaNameModified, const std::string &tableName, const TableSchema &tbl, bool reverse )
38✔
408
{
409
  std::string exprPk;
38✔
410
  for ( const TableColumnInfo &c : tbl.columns )
196✔
411
  {
412
    if ( c.isPrimaryKey )
158✔
413
    {
414
      if ( !exprPk.empty() )
38✔
415
        exprPk += " AND ";
×
416
      exprPk += quotedIdentifier( schemaNameBase ) + "." + quotedIdentifier( tableName ) + "." + quotedIdentifier( c.name ) + "=" +
76✔
417
                quotedIdentifier( schemaNameModified ) + "." + quotedIdentifier( tableName ) + "." + quotedIdentifier( c.name );
114✔
418
    }
419
  }
420

421
  std::string sql = "SELECT " + allColumnNames( tbl ) + " FROM " +
76✔
422
                    quotedIdentifier( reverse ? schemaNameBase : schemaNameModified ) + "." + quotedIdentifier( tableName ) +
152✔
423
                    " WHERE NOT EXISTS ( SELECT 1 FROM " +
76✔
424
                    quotedIdentifier( reverse ? schemaNameModified : schemaNameBase ) + "." + quotedIdentifier( tableName ) +
152✔
425
                    " WHERE " + exprPk + ")";
76✔
426
  return sql;
76✔
427
}
38✔
428

429

430
static std::string sqlFindModified( const std::string &schemaNameBase, const std::string &schemaNameModified, const std::string &tableName, const TableSchema &tbl )
19✔
431
{
432
  std::string exprPk;
19✔
433
  std::string exprOther;
19✔
434
  for ( const TableColumnInfo &c : tbl.columns )
98✔
435
  {
436
    if ( c.isPrimaryKey )
79✔
437
    {
438
      if ( !exprPk.empty() )
19✔
439
        exprPk += " AND ";
×
440
      exprPk += "b." + quotedIdentifier( c.name ) + "=" +
38✔
441
                "a." + quotedIdentifier( c.name );
57✔
442
    }
443
    else // not a primary key column
444
    {
445
      if ( !exprOther.empty() )
60✔
446
        exprOther += " OR ";
42✔
447

448
      std::string colBase = "b." + quotedIdentifier( c.name );
60✔
449
      std::string colModified = "a." + quotedIdentifier( c.name );
60✔
450

451
      // pg IS DISTINCT FROM operator handles comparison between null and not null values. When comparing values with basic `=` operator,
452
      // comparison 7 = NULL returns null (no rows), not false as one would expect. IS DISTINCT FROM handles this and returns false in such situations.
453
      // When comparing non-null values with IS DISTINCT FROM operator, it works just as `=` does with non-null values.
454
      exprOther += "(" + colBase + " IS DISTINCT FROM " + colModified + ")";
60✔
455
    }
60✔
456
  }
457

458
  std::string sql = "SELECT " + allColumnNames( tbl, "a" ) + ", " + allColumnNames( tbl, "b" ) + " FROM " +
38✔
459
                    quotedIdentifier( schemaNameModified ) + "." + quotedIdentifier( tableName ) + " a, " +
76✔
460
                    quotedIdentifier( schemaNameBase ) + "." + quotedIdentifier( tableName ) + " b" +
76✔
461
                    " WHERE " + exprPk;
19✔
462

463
  if ( !exprOther.empty() )
19✔
464
    sql += " AND (" + exprOther + ")";
18✔
465

466
  return sql;
38✔
467
}
19✔
468

469

470
static bool isColumnInt( const TableColumnInfo &col )
277✔
471
{
472
  return col.type == "integer" || col.type == "smallint" || col.type == "bigint";
277✔
473
}
474

475
static bool isColumnDouble( const TableColumnInfo &col )
169✔
476
{
477
  return col.type == "real" || col.type == "double precision" ||
507✔
478
         startsWith( col.type.dbType, "numeric" ) || startsWith( col.type.dbType, "decimal" );
676✔
479
}
480

481
static bool isColumnText( const TableColumnInfo &col )
152✔
482
{
483
  return col.type == "text" || startsWith( col.type.dbType, "text(" ) ||
474✔
484
         col.type == "varchar" || startsWith( col.type.dbType, "varchar(" ) ||
407✔
485
         col.type == "character varying" || startsWith( col.type.dbType, "character varying(" ) ||
404✔
486
         col.type == "char" || startsWith( col.type.dbType, "char(" ) || startsWith( col.type.dbType, "character(" ) ||
706✔
487
         col.type == "citext";
386✔
488
}
489

490
static bool isColumnGeometry( const TableColumnInfo &col )
73✔
491
{
492
  return col.isGeometry;
73✔
493
}
494

495
static Value resultToValue( const PostgresResult &res, int r, size_t i, const TableColumnInfo &col )
289✔
496
{
497
  Value v;
289✔
498
  if ( res.isNull( r, i ) )
289✔
499
  {
500
    v.setNull();
11✔
501
  }
502
  else
503
  {
504
    std::string valueStr = res.value( r, i );
278✔
505
    if ( col.type == "bool" || col.type == "boolean" )
278✔
506
    {
507
      v.setInt( valueStr == "t" );  // PostgreSQL uses 't' for true and 'f' for false
1✔
508
    }
509
    else if ( isColumnInt( col ) )
277✔
510
    {
511
      v.setInt( atol( valueStr.c_str() ) );
108✔
512
    }
513
    else if ( isColumnDouble( col ) )
169✔
514
    {
515
      v.setDouble( atof( valueStr.c_str() ) );
17✔
516
    }
517
    else if ( isColumnText( col ) || col.type == "uuid" )
152✔
518
    {
519
      v.setString( Value::TypeText, valueStr.c_str(), valueStr.size() );
71✔
520
    }
521
    else if ( col.type == "timestamp without time zone" || col.type == "date" )
81✔
522
    {
523
      v.setString( Value::TypeText, valueStr.c_str(), valueStr.size() );
8✔
524
    }
525
    else if ( isColumnGeometry( col ) )
73✔
526
    {
527
      // the value we get should have this format: "\x01234567890abcdef"
528
      if ( valueStr.size() < 2 )
73✔
529
        throw GeoDiffException( "Unexpected geometry value" );
×
530
      if ( valueStr[0] != '\\' || valueStr[1] != 'x' )
73✔
531
        throw GeoDiffException( "Unexpected prefix in geometry value" );
×
532

533
      // 1. convert from hex representation to proper binary stream
534
      std::string binString = hex2bin( valueStr.substr( 2 ) ); // chop \x prefix
73✔
535

536
      // 2. create binary header
537
      std::string binHead = createGpkgHeader( binString, col );
73✔
538

539
      // 3. copy header and body
540
      std::string gpb( binHead.size() + binString.size(), 0 );
73✔
541

542
      memcpy( &gpb[0], binHead.data(), binHead.size() );
73✔
543
      memcpy( &gpb[binHead.size()], binString.data(), binString.size() );
73✔
544

545
      v.setString( Value::TypeBlob, gpb.data(), gpb.size() );
73✔
546
    }
73✔
547
    else
548
    {
549
      // TODO: handling of other types (list, blob, ...)
550
      throw GeoDiffException( "unknown value type: " + col.type.dbType );
×
551
    }
552
  }
278✔
553
  return v;
289✔
554
}
×
555

556
static std::string valueToSql( const Value &v, const TableColumnInfo &col )
332✔
557
{
558
  if ( v.type() == Value::TypeUndefined )
332✔
559
  {
560
    throw GeoDiffException( "valueToSql: got 'undefined' value (malformed changeset?)" );
×
561
  }
562
  else if ( v.type() == Value::TypeNull )
332✔
563
  {
564
    return "NULL";
12✔
565
  }
566
  else if ( v.type() == Value::TypeInt )
320✔
567
  {
568
    if ( col.type == "boolean" )
114✔
569
      return v.getInt() ? "'t'" : "'f'";
3✔
570
    else
571
      return std::to_string( v.getInt() );
111✔
572
  }
573
  else if ( v.type() == Value::TypeDouble )
206✔
574
  {
575
    return to_string_with_max_precision( v.getDouble() );
17✔
576
  }
577
  else if ( v.type() == Value::TypeText || v.type() == Value::TypeBlob )
189✔
578
  {
579
    if ( col.isGeometry )
189✔
580
    {
581
      // handling of geometries - they are encoded with GPKG header
582
      std::string gpkgWkb = v.getString();
76✔
583
      int headerSize = parseGpkgbHeaderSize( gpkgWkb );
76✔
584
      std::string wkb( gpkgWkb.size() - headerSize, 0 );
76✔
585

586
      memcpy( &wkb[0], &gpkgWkb[headerSize], gpkgWkb.size() - headerSize );
76✔
587
      return "ST_GeomFromWKB('\\x" + bin2hex( wkb ) + "', " + std::to_string( col.geomSrsId ) + ")";
152✔
588
    }
76✔
589
    return quotedString( v.getString() );
113✔
590
  }
591
  else
592
  {
593
    throw GeoDiffException( "unexpected value" );
×
594
  }
595
}
596

597

598
static void handleInserted( const std::string &schemaNameBase, const std::string &schemaNameModified, const std::string &tableName, const TableSchema &tbl, bool reverse, PGconn *conn, ChangesetWriter &writer, bool &first )
38✔
599
{
600
  std::string sqlInserted = sqlFindInserted( schemaNameBase, schemaNameModified, tableName, tbl, reverse );
38✔
601
  PostgresResult res( execSql( conn, sqlInserted ) );
38✔
602

603
  int rows = res.rowCount();
38✔
604
  for ( int r = 0; r < rows; ++r )
51✔
605
  {
606
    if ( first )
13✔
607
    {
608
      ChangesetTable chTable = schemaToChangesetTable( tableName, tbl );
10✔
609
      writer.beginTable( chTable );
10✔
610
      first = false;
10✔
611
    }
10✔
612

613
    ChangesetEntry e;
13✔
614
    e.op = reverse ? ChangesetEntry::OpDelete : ChangesetEntry::OpInsert;
13✔
615

616
    size_t numColumns = tbl.columns.size();
13✔
617
    for ( size_t i = 0; i < numColumns; ++i )
53✔
618
    {
619
      Value v( resultToValue( res, r, i, tbl.columns[i] ) );
40✔
620
      if ( reverse )
40✔
621
        e.oldValues.push_back( v );
4✔
622
      else
623
        e.newValues.push_back( v );
36✔
624
    }
40✔
625

626
    writer.writeEntry( e );
13✔
627
  }
13✔
628
}
38✔
629

630

631
static void handleUpdated( const std::string &schemaNameBase, const std::string &schemaNameModified, const std::string &tableName, const TableSchema &tbl, PGconn *conn, ChangesetWriter &writer, bool &first )
19✔
632
{
633
  std::string sqlModified = sqlFindModified( schemaNameBase, schemaNameModified, tableName, tbl );
19✔
634
  PostgresResult res( execSql( conn, sqlModified ) );
19✔
635

636
  int rows = res.rowCount();
19✔
637
  for ( int r = 0; r < rows; ++r )
24✔
638
  {
639
    if ( first )
5✔
640
    {
641
      ChangesetTable chTable = schemaToChangesetTable( tableName, tbl );
2✔
642
      writer.beginTable( chTable );
2✔
643
      first = false;
2✔
644
    }
2✔
645

646
    /*
647
    ** Within the old.* record associated with an UPDATE change, all fields
648
    ** associated with table columns that are not PRIMARY KEY columns and are
649
    ** not modified by the UPDATE change are set to "undefined". Other fields
650
    ** are set to the values that made up the row before the UPDATE that the
651
    ** change records took place. Within the new.* record, fields associated
652
    ** with table columns modified by the UPDATE change contain the new
653
    ** values. Fields associated with table columns that are not modified
654
    ** are set to "undefined".
655
    */
656

657
    ChangesetEntry e;
5✔
658
    e.op = ChangesetEntry::OpUpdate;
5✔
659

660
    size_t numColumns = tbl.columns.size();
5✔
661
    for ( size_t i = 0; i < numColumns; ++i )
23✔
662
    {
663
      Value v1( resultToValue( res, r, i + numColumns, tbl.columns[i] ) );
18✔
664
      Value v2( resultToValue( res, r, i, tbl.columns[i] ) );
18✔
665
      bool pkey = tbl.columns[i].isPrimaryKey;
18✔
666
      bool updated = v1 != v2;
18✔
667
      e.oldValues.push_back( ( pkey || updated ) ? v1 : Value() );
29✔
668
      e.newValues.push_back( updated ? v2 : Value() );
18✔
669
    }
18✔
670

671
    writer.writeEntry( e );
5✔
672
  }
5✔
673
}
19✔
674

675

676
void PostgresDriver::createChangeset( ChangesetWriter &writer )
16✔
677
{
678
  if ( !mConn )
16✔
679
    throw GeoDiffException( "Not connected to a database" );
×
680

681
  std::vector<std::string> tablesBase = listTables( false );
16✔
682
  std::vector<std::string> tablesModified = listTables( true );
16✔
683

684
  if ( tablesBase != tablesModified )
16✔
685
  {
686
    throw GeoDiffException( "Table names are not matching between the input databases.\n"
×
687
                            "Base:     " + concatNames( tablesBase ) + "\n" +
×
688
                            "Modified: " + concatNames( tablesModified ) );
×
689
  }
690

691
  for ( const std::string &tableName : tablesBase )
35✔
692
  {
693
    TableSchema tbl = tableSchema( tableName );
19✔
694
    TableSchema tblNew = tableSchema( tableName, true );
19✔
695

696
    // test that table schema in the modified is the same
697
    if ( tbl != tblNew )
19✔
698
    {
699
      if ( !tbl.compareWithBaseTypes( tblNew ) )
×
700
        throw GeoDiffException( "PostgreSQL Table schemas are not the same for table: " + tableName );
×
701
    }
702

703
    if ( !tbl.hasPrimaryKey() )
19✔
704
      continue;  // ignore tables without primary key - they can't be compared properly
×
705

706
    bool first = true;
19✔
707

708
    handleInserted( mBaseSchema, mModifiedSchema, tableName, tbl, false, mConn, writer, first );  // INSERT
19✔
709
    handleInserted( mBaseSchema, mModifiedSchema, tableName, tbl, true, mConn, writer, first );   // DELETE
19✔
710
    handleUpdated( mBaseSchema, mModifiedSchema, tableName, tbl, mConn, writer, first );          // UPDATE
19✔
711
  }
19✔
712
}
16✔
713

714

715
static std::string sqlForInsert( const std::string &schemaName, const std::string &tableName, const TableSchema &tbl, const std::vector<Value> &values )
76✔
716
{
717
  /*
718
   * For a table defined like this: CREATE TABLE x(a, b, c, d, PRIMARY KEY(a, c));
719
   *
720
   * INSERT INTO x (a, b, c, d) VALUES (?, ?, ?, ?)
721
   */
722

723
  std::string sql;
76✔
724
  sql += "INSERT INTO " + quotedIdentifier( schemaName ) + "." + quotedIdentifier( tableName ) + " (";
76✔
725
  for ( size_t i = 0; i < tbl.columns.size(); ++i )
376✔
726
  {
727
    if ( i > 0 )
300✔
728
      sql += ", ";
224✔
729
    sql += quotedIdentifier( tbl.columns[i].name );
300✔
730
  }
731
  sql += ") VALUES (";
76✔
732
  for ( size_t i = 0; i < tbl.columns.size(); ++i )
376✔
733
  {
734
    if ( i > 0 )
300✔
735
      sql += ", ";
224✔
736
    sql += valueToSql( values[i], tbl.columns[i] );
300✔
737
  }
738
  sql += ")";
76✔
739
  return sql;
76✔
740
}
×
741

742

743
static std::string sqlForUpdate( const std::string &schemaName, const std::string &tableName, const TableSchema &tbl, const std::vector<Value> &oldValues, const std::vector<Value> &newValues )
5✔
744
{
745
  std::string sql;
5✔
746
  sql += "UPDATE " + quotedIdentifier( schemaName ) + "." + quotedIdentifier( tableName ) + " SET ";
5✔
747

748
  bool first = true;
5✔
749
  for ( size_t i = 0; i < tbl.columns.size(); ++i )
22✔
750
  {
751
    if ( newValues[i].type() != Value::TypeUndefined )
17✔
752
    {
753
      if ( !first )
8✔
754
        sql += ", ";
3✔
755
      first = false;
8✔
756
      sql += quotedIdentifier( tbl.columns[i].name ) + " = " + valueToSql( newValues[i], tbl.columns[i] );
8✔
757
    }
758
  }
759
  first = true;
5✔
760
  sql += " WHERE ";
5✔
761
  for ( size_t i = 0; i < tbl.columns.size(); ++i )
22✔
762
  {
763
    if ( oldValues[i].type() != Value::TypeUndefined )
17✔
764
    {
765
      if ( !first )
13✔
766
        sql += " AND ";
8✔
767
      first = false;
13✔
768
      sql += quotedIdentifier( tbl.columns[i].name );
13✔
769
      if ( oldValues[i].type() == Value::TypeNull )
13✔
770
        sql += " IS NULL";
1✔
771
      else
772
        sql += " = " + valueToSql( oldValues[i], tbl.columns[i] );
12✔
773
    }
774
  }
775

776
  return sql;
5✔
777
}
×
778

779

780
static std::string sqlForDelete( const std::string &schemaName, const std::string &tableName, const TableSchema &tbl, const std::vector<Value> &values )
3✔
781
{
782
  std::string sql;
3✔
783
  sql += "DELETE FROM " + quotedIdentifier( schemaName ) + "." + quotedIdentifier( tableName ) + " WHERE ";
3✔
784
  for ( size_t i = 0; i < tbl.columns.size(); ++i )
15✔
785
  {
786
    if ( i > 0 )
12✔
787
      sql += " AND ";
9✔
788
    if ( tbl.columns[i].isPrimaryKey )
12✔
789
      sql += quotedIdentifier( tbl.columns[i].name ) + " = " + valueToSql( values[i], tbl.columns[i] );
3✔
790
    else
791
    {
792
      if ( values[i].type() == Value::TypeNull )
9✔
793
        sql += quotedIdentifier( tbl.columns[i].name ) + " IS NULL";
×
794
      else
795
        sql += quotedIdentifier( tbl.columns[i].name ) + " = " + valueToSql( values[i], tbl.columns[i] ) ;
9✔
796
    }
797
  }
798
  return sql;
3✔
799
}
×
800

801

802
void PostgresDriver::applyChangeset( ChangesetReader &reader )
24✔
803
{
804
  if ( !mConn )
24✔
805
    throw GeoDiffException( "Not connected to a database" );
×
806

807
  std::string lastTableName;
24✔
808
  TableSchema tbl;
24✔
809

810
  int autoIncrementPkeyIndex = -1;
24✔
811
  std::map<std::string, int64_t> autoIncrementTablesToFix;  // key = table name, value = max. value in changeset
24✔
812
  std::map<std::string, std::string> tableNameToSequenceName;  // key = table name, value = name of its pkey's sequence object
24✔
813

814
  // start a transaction, so that all changes get committed at once (or nothing get committed)
815
  PostgresTransaction transaction( mConn );
24✔
816

817
  int conflictCount = 0;
24✔
818
  ChangesetEntry entry;
24✔
819
  while ( reader.nextEntry( entry ) )
108✔
820
  {
821
    std::string tableName = entry.table->name;
85✔
822

823
    if ( startsWith( tableName, "gpkg_" ) )
85✔
824
      continue;   // skip any changes to GPKG meta tables
1✔
825

826
    // skip table if necessary
827
    if ( context()->isTableSkipped( tableName ) )
84✔
828
    {
829
      continue;
×
830
    }
831

832
    if ( tableName != lastTableName )
84✔
833
    {
834
      lastTableName = tableName;
44✔
835
      tbl = tableSchema( tableName );
44✔
836

837
      if ( tbl.columns.size() == 0 )
44✔
838
        throw GeoDiffException( "No such table: " + tableName );
×
839

840
      if ( tbl.columns.size() != entry.table->columnCount() )
44✔
841
        throw GeoDiffException( "Wrong number of columns for table: " + tableName );
×
842

843
      for ( size_t i = 0; i < entry.table->columnCount(); ++i )
232✔
844
      {
845
        if ( tbl.columns[i].isPrimaryKey != entry.table->primaryKeys[i] )
188✔
846
          throw GeoDiffException( "Mismatch of primary keys in table: " + tableName );
×
847
      }
848

849
      // if a table has auto-incrementing pkey (using SEQUENCE object), we may need
850
      // to update the sequence value after doing some inserts (or subsequent INSERTs would fail)
851
      std::string seqName = getSequenceObjectName( tbl, autoIncrementPkeyIndex );
44✔
852
      if ( autoIncrementPkeyIndex != -1 )
44✔
853
        tableNameToSequenceName[tableName] = seqName;
44✔
854
    }
44✔
855

856
    if ( entry.op == ChangesetEntry::OpInsert )
84✔
857
    {
858
      std::string sql = sqlForInsert( mBaseSchema, tableName, tbl, entry.newValues );
76✔
859
      PostgresResult res( execSql( mConn, sql ) );
76✔
860
      if ( res.status() != PGRES_COMMAND_OK )
76✔
861
      {
862
        logApplyConflict( "insert_failed", entry );
×
863
        ++conflictCount;
×
864
        context()->logger().warn( "Failure doing INSERT: " + res.statusErrorMessage() );
×
865
      }
866
      if ( res.affectedRows() != "1" )
76✔
867
      {
868
        throw GeoDiffException( "Wrong number of affected rows! Expected 1, got: " + res.affectedRows() );
×
869
      }
870

871
      if ( autoIncrementPkeyIndex != -1 )
76✔
872
      {
873
        int64_t pkey = entry.newValues[autoIncrementPkeyIndex].getInt();
76✔
874
        if ( autoIncrementTablesToFix.find( tableName ) == autoIncrementTablesToFix.end() )
76✔
875
          autoIncrementTablesToFix[tableName] = pkey;
40✔
876
        else
877
          autoIncrementTablesToFix[tableName] = std::max( autoIncrementTablesToFix[tableName], pkey );
36✔
878
      }
879
    }
76✔
880
    else if ( entry.op == ChangesetEntry::OpUpdate )
8✔
881
    {
882
      std::string sql = sqlForUpdate( mBaseSchema, tableName, tbl, entry.oldValues, entry.newValues );
5✔
883
      PostgresResult res( execSql( mConn, sql ) );
5✔
884
      if ( res.status() != PGRES_COMMAND_OK )
5✔
885
      {
886
        logApplyConflict( "update_failed", entry );
×
887
        ++conflictCount;
×
888
        context()->logger().warn( "Failure doing UPDATE: " + res.statusErrorMessage() );
×
889
      }
890
      if ( res.affectedRows() != "1" )
5✔
891
      {
892
        logApplyConflict( "update_nothing", entry );
×
893
        ++conflictCount;
×
894
        context()->logger().warn( "Wrong number of affected rows! Expected 1, got: " + res.affectedRows() + "\nSQL: " + sql );
×
895
      }
896
    }
5✔
897
    else if ( entry.op == ChangesetEntry::OpDelete )
3✔
898
    {
899
      std::string sql = sqlForDelete( mBaseSchema, tableName, tbl, entry.oldValues );
3✔
900
      PostgresResult res( execSql( mConn, sql ) );
3✔
901
      if ( res.status() != PGRES_COMMAND_OK )
2✔
902
      {
903
        logApplyConflict( "delete_failed", entry );
×
904
        ++conflictCount;
×
905
        context()->logger().warn( "Failure doing DELETE: " + res.statusErrorMessage() );
×
906
      }
907
      if ( res.affectedRows() != "1" )
2✔
908
      {
909
        logApplyConflict( "delete_nothing", entry );
×
910
        context()->logger().warn( "Wrong number of affected rows! Expected 1, got: " + res.affectedRows() );
×
911
      }
912
    }
3✔
913
    else
914
      throw GeoDiffException( "Unexpected operation" );
×
915
  }
85✔
916

917
  // at the end, update any SEQUENCE objects if needed
918
  for ( const auto &it : autoIncrementTablesToFix )
63✔
919
    updateSequenceObject( tableNameToSequenceName[it.first], it.second );
40✔
920

921
  if ( !conflictCount )
23✔
922
  {
923
    transaction.commitChanges();
23✔
924
  }
925
  else
926
  {
927
    throw GeoDiffException( "Conflicts encountered while applying changes! Total " + std::to_string( conflictCount ) );
×
928
  }
929
}
29✔
930

931
std::string PostgresDriver::getSequenceObjectName( const TableSchema &tbl, int &autoIncrementPkeyIndex )
44✔
932
{
933
  std::string colName;
44✔
934
  autoIncrementPkeyIndex = -1;
44✔
935
  for ( size_t i = 0; i < tbl.columns.size(); ++i )
44✔
936
  {
937
    if ( tbl.columns[i].isPrimaryKey && tbl.columns[i].isAutoIncrement )
44✔
938
    {
939
      autoIncrementPkeyIndex = i;
44✔
940
      colName = tbl.columns[i].name;
44✔
941
      break;
44✔
942
    }
943
  }
944

945
  if ( autoIncrementPkeyIndex == -1 )
44✔
946
    return "";
×
947

948
  std::string tableNameString = quotedIdentifier( mBaseSchema ) + "." + quotedIdentifier( tbl.name );
88✔
949
  std::string sql = "select pg_get_serial_sequence(" + quotedString( tableNameString ) + ", " + quotedString( colName ) + ")";
88✔
950
  PostgresResult resBase( execSql( mConn, sql ) );
44✔
951
  if ( resBase.rowCount() != 1 )
44✔
952
    throw GeoDiffException( "Unable to find sequence object for auto-incrementing pkey for table " + tbl.name );
×
953

954
  return resBase.value( 0, 0 );
44✔
955
}
44✔
956

957
void PostgresDriver::updateSequenceObject( const std::string &seqName, int64_t maxValue )
40✔
958
{
959
  PostgresResult resCurrVal( execSql( mConn, "SELECT last_value FROM " + seqName ) );
40✔
960
  std::string currValueStr = resCurrVal.value( 0, 0 );
40✔
961
  int currValue = std::stoi( currValueStr );
40✔
962

963
  if ( currValue < maxValue )
40✔
964
  {
965
    context()->logger().info( "Updating sequence " + seqName + " from " + std::to_string( currValue ) + " to " + std::to_string( maxValue ) );
20✔
966

967
    std::string sql = "SELECT setval(" + quotedString( seqName ) + ", " + std::to_string( maxValue ) + ")";
40✔
968
    PostgresResult resSetVal( execSql( mConn, sql ) );
20✔
969
    // the SQL just returns the new value we set
970
  }
20✔
971
}
40✔
972

973
void PostgresDriver::createTables( const std::vector<TableSchema> &tables )
16✔
974
{
975
  for ( const TableSchema &tbl : tables )
53✔
976
  {
977
    if ( startsWith( tbl.name, "gpkg_" ) )
37✔
978
      continue;   // skip any changes to GPKG meta tables
×
979

980
    std::string sql, pkeyCols, columns;
37✔
981
    for ( const TableColumnInfo &c : tbl.columns )
195✔
982
    {
983
      if ( !columns.empty() )
158✔
984
        columns += ", ";
121✔
985

986
      std::string type = c.type.dbType;
158✔
987
      if ( c.isAutoIncrement )
158✔
988
        type = "SERIAL";   // there is also "smallserial", "bigserial" ...
37✔
989
      columns += quotedIdentifier( c.name ) + " " + type;
158✔
990

991
      if ( c.isNotNull )
158✔
992
        columns += " NOT NULL";
36✔
993

994
      if ( c.isPrimaryKey )
158✔
995
      {
996
        if ( !pkeyCols.empty() )
37✔
997
          pkeyCols += ", ";
×
998
        pkeyCols += quotedIdentifier( c.name );
37✔
999
      }
1000
    }
158✔
1001

1002
    sql = "CREATE TABLE " + quotedIdentifier( mBaseSchema ) + "." + quotedIdentifier( tbl.name ) + " (";
37✔
1003
    sql += columns;
37✔
1004
    sql += ", PRIMARY KEY (" + pkeyCols + ")";
37✔
1005
    sql += ");";
37✔
1006

1007
    PostgresResult res( execSql( mConn, sql ) );
37✔
1008
    if ( res.status() != PGRES_COMMAND_OK )
37✔
1009
      throw GeoDiffException( "Failure creating table: " + res.statusErrorMessage() );
×
1010
  }
37✔
1011
}
16✔
1012

1013

1014
void PostgresDriver::dumpData( ChangesetWriter &writer, bool useModified )
15✔
1015
{
1016
  if ( !mConn )
15✔
1017
    throw GeoDiffException( "Not connected to a database" );
×
1018

1019
  std::vector<std::string> tables = listTables();
15✔
1020
  for ( const std::string &tableName : tables )
43✔
1021
  {
1022
    TableSchema tbl = tableSchema( tableName, useModified );
28✔
1023
    if ( !tbl.hasPrimaryKey() )
28✔
1024
      continue;  // ignore tables without primary key - they can't be compared properly
×
1025

1026
    std::string sql = "SELECT " + allColumnNames( tbl ) + " FROM " +
56✔
1027
                      quotedIdentifier( useModified ? mModifiedSchema : mBaseSchema ) + "." + quotedIdentifier( tableName );
112✔
1028

1029
    PostgresResult res( execSql( mConn, sql ) );
28✔
1030
    int rows = res.rowCount();
28✔
1031
    for ( int r = 0; r < rows; ++r )
83✔
1032
    {
1033
      if ( r == 0 )
55✔
1034
      {
1035
        writer.beginTable( schemaToChangesetTable( tableName, tbl ) );
28✔
1036
      }
1037

1038
      ChangesetEntry e;
55✔
1039
      e.op = ChangesetEntry::OpInsert;
55✔
1040
      size_t numColumns = tbl.columns.size();
55✔
1041
      for ( size_t i = 0; i < numColumns; ++i )
268✔
1042
      {
1043
        e.newValues.push_back( Value( resultToValue( res, r, i, tbl.columns[i] ) ) );
213✔
1044
      }
1045
      writer.writeEntry( e );
55✔
1046
    }
55✔
1047
  }
28✔
1048
}
15✔
1049

1050
void PostgresDriver::checkCompatibleForRebase( bool )
×
1051
{
1052
  throw GeoDiffException( "Rebase with postgres not supported yet" );
×
1053
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc