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

rm-hull / gps-routes-api / 24308993190

12 Apr 2026 02:28PM UTC coverage: 35.467% (-11.8%) from 47.258%
24308993190

Pull #121

github

rm-hull
feat: add database dialect abstraction

Introduce a `Dialect` interface to support cross-database compatibility,
allowing the `QueryBuilder` to generate syntax specific to PostgreSQL
and SQLite.

```mermaid
classDiagram
    class Dialect {
        <<interface>>
        +FormatParam(int) string
        +BuildArrayOverlapQuery(string, string) string
        +BuildFullTextQuery(string, string) string
        +PrepareParam(any) any
    }
    class PostgreSQLDialect
    class SQLiteDialect
    class QueryBuilder {
        -Dialect dialect
    }

    Dialect <|.. PostgreSQLDialect
    Dialect <|.. SQLiteDialect
    QueryBuilder --> Dialect
```
Pull Request #121: Feat/issue 120/phase 4 query layer refactoring

81 of 641 new or added lines in 5 files covered. (12.64%)

687 of 1937 relevant lines covered (35.47%)

0.38 hits per line

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

0.0
/cmds/migrate_postgres_to_sqlite.go
1
package cmds
2

3
import (
4
        "context"
5
        "database/sql"
6
        "fmt"
7
        "log"
8
        "os"
9
        "time"
10

11
        "github.com/jackc/pgx/v5/pgxpool"
12
        _ "github.com/mattn/go-sqlite3"
13
        "github.com/rm-hull/gps-routes-api/db"
14
)
15

16
// MigratePostgresToSQLite performs data migration from PostgreSQL to SQLite
NEW
17
func MigratePostgresToSQLite(pgConnStr, sqliteFile string, dryRun bool, maxRecords int) {
×
NEW
18
        var finalConnStr string
×
NEW
19

×
NEW
20
        if pgConnStr == "" {
×
NEW
21
                // Build connection string from environment variables using the same pattern as ping_database.go
×
NEW
22
                config := db.ConfigFromEnv()
×
NEW
23
                finalConnStr = fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s search_path=%s",
×
NEW
24
                        config.Host, config.Port, config.User, config.Password, config.DBName, config.SSLMode, config.Schema+",public")
×
NEW
25
                log.Printf("🔧 Using PostgreSQL connection from environment variables: %s@%s:%d/%s",
×
NEW
26
                        config.User, config.Host, config.Port, config.DBName)
×
NEW
27
        } else {
×
NEW
28
                finalConnStr = pgConnStr
×
NEW
29
        }
×
30

NEW
31
        log.Printf("🔄 SQLite3 Migration Tool")
×
NEW
32
        log.Printf("📊 Source (PostgreSQL): %s", maskConnStr(finalConnStr))
×
NEW
33
        log.Printf("📁 Target (SQLite): %s", sqliteFile)
×
NEW
34
        if dryRun {
×
NEW
35
                log.Printf("⚠️  DRY RUN: Validation only, no writes")
×
NEW
36
        }
×
37

38
        // Connect to PostgreSQL
NEW
39
        pgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
×
NEW
40
        defer cancel()
×
NEW
41

×
NEW
42
        pgPool, err := pgxpool.New(pgCtx, finalConnStr)
×
NEW
43
        if err != nil {
×
NEW
44
                log.Fatalf("❌ Failed to connect to PostgreSQL: %v", err)
×
NEW
45
        }
×
NEW
46
        defer pgPool.Close()
×
NEW
47

×
NEW
48
        log.Print("✅ Connected to PostgreSQL")
×
NEW
49

×
NEW
50
        // Validate PostgreSQL has data
×
NEW
51
        var pgCount int64
×
NEW
52
        err = pgPool.QueryRow(context.Background(), "SELECT COUNT(*) FROM routes").Scan(&pgCount)
×
NEW
53
        if err != nil {
×
NEW
54
                log.Fatalf("❌ Failed to query PostgreSQL: %v", err)
×
NEW
55
        }
×
NEW
56
        log.Printf("📈 PostgreSQL routes count: %d", pgCount)
×
NEW
57

×
NEW
58
        if pgCount == 0 {
×
NEW
59
                log.Fatal("❌ No routes found in PostgreSQL database")
×
NEW
60
        }
×
61

NEW
62
        if maxRecords > 0 && int64(maxRecords) < pgCount {
×
NEW
63
                log.Printf("⏱️  Limiting migration to %d records (out of %d available)", maxRecords, pgCount)
×
NEW
64
                pgCount = int64(maxRecords)
×
NEW
65
        }
×
66

NEW
67
        expectedCount := pgCount
×
NEW
68

×
NEW
69
        // Create/prepare SQLite database
×
NEW
70
        if err := setupSQLiteDB(sqliteFile, dryRun); err != nil {
×
NEW
71
                log.Fatalf("❌ Failed to setup SQLite: %v", err)
×
NEW
72
        }
×
73

NEW
74
        if dryRun {
×
NEW
75
                log.Print("🔍 Dry-run mode: skipping data migration")
×
NEW
76
                os.Exit(0)
×
NEW
77
        }
×
78

79
        // Migrate routes
NEW
80
        sqliteDB, err := sql.Open("sqlite3", sqliteFile)
×
NEW
81
        if err != nil {
×
NEW
82
                log.Fatalf("❌ Failed to open SQLite: %v", err)
×
NEW
83
        }
×
NEW
84
        defer sqliteDB.Close()
×
NEW
85

×
NEW
86
        log.Print("🚀 Starting data migration...")
×
NEW
87
        migrated, err := migrateRoutes(pgPool, sqliteDB, maxRecords)
×
NEW
88
        if err != nil {
×
NEW
89
                log.Fatalf("❌ Migration failed: %v", err)
×
NEW
90
        }
×
91

NEW
92
        log.Printf("✅ Routes migrated: %d", migrated)
×
NEW
93

×
NEW
94
        expectedCount = migrated
×
NEW
95

×
NEW
96
        nearbyMigrated, err := migrateNearby(pgPool, sqliteDB)
×
NEW
97
        if err != nil {
×
NEW
98
                log.Printf("⚠️  Nearby migration failed (non-critical): %v", err)
×
NEW
99
        } else {
×
NEW
100
                log.Printf("✅ Nearby records migrated: %d", nearbyMigrated)
×
NEW
101
        }
×
102

NEW
103
        imagesMigrated, err := migrateImages(pgPool, sqliteDB)
×
NEW
104
        if err != nil {
×
NEW
105
                log.Printf("⚠️  Images migration failed (non-critical): %v", err)
×
NEW
106
        } else {
×
NEW
107
                log.Printf("✅ Images migrated: %d", imagesMigrated)
×
NEW
108
        }
×
109

NEW
110
        detailsMigrated, err := migrateDetails(pgPool, sqliteDB)
×
NEW
111
        if err != nil {
×
NEW
112
                log.Printf("⚠️  Details migration failed (non-critical): %v", err)
×
NEW
113
        } else {
×
NEW
114
                log.Printf("✅ Details migrated: %d", detailsMigrated)
×
NEW
115
        }
×
116

117
        // Validate counts match
NEW
118
        log.Print("🔍 Validating migration...")
×
NEW
119
        if err := validateMigration(pgPool, sqliteDB, expectedCount); err != nil {
×
NEW
120
                log.Fatalf("❌ Validation failed: %v", err)
×
NEW
121
        }
×
122

NEW
123
        log.Print("✅ Migration validation passed!")
×
NEW
124
        log.Printf("📊 Final SQLite database: %s", sqliteFile)
×
125
}
126

NEW
127
func setupSQLiteDB(dbPath string, dryRun bool) error {
×
NEW
128
        if dryRun {
×
NEW
129
                // Use in-memory DB for dry-run
×
NEW
130
                dbPath = ":memory:"
×
NEW
131
        }
×
132

133
        // Try with FTS5 enabled connection string
NEW
134
        db, err := sql.Open("sqlite3", dbPath+"?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)")
×
NEW
135
        if err != nil {
×
NEW
136
                return err
×
NEW
137
        }
×
NEW
138
        defer db.Close()
×
NEW
139

×
NEW
140
        // Try to enable FTS5 if available by creating a temporary FTS5 virtual table.
×
NEW
141
        _, err = db.Exec("CREATE VIRTUAL TABLE IF NOT EXISTS __fts5_check__ USING fts5(content)")
×
NEW
142
        if err != nil {
×
NEW
143
                log.Printf("⚠️  FTS5 not available, creating schema without full-text search: %v", err)
×
NEW
144
                return createSchemaWithoutFTS5(db)
×
NEW
145
        }
×
146

NEW
147
        _, err = db.Exec("DROP TABLE IF EXISTS __fts5_check__")
×
NEW
148
        if err != nil {
×
NEW
149
                return fmt.Errorf("failed to clean up temporary FTS5 table: %w", err)
×
NEW
150
        }
×
151

152
        // Read and execute full schema with FTS5
NEW
153
        schema, err := os.ReadFile("db/migrations/00002_sqlite_schema.sql")
×
NEW
154
        if err != nil {
×
NEW
155
                return fmt.Errorf("failed to read schema file: %w", err)
×
NEW
156
        }
×
157

158
        // Execute schema creation
NEW
159
        if _, err := db.Exec(string(schema)); err != nil {
×
NEW
160
                return fmt.Errorf("failed to create schema: %w", err)
×
NEW
161
        }
×
162

NEW
163
        return nil
×
164
}
165

NEW
166
func migrateRoutes(pgPool *pgxpool.Pool, sqliteDB *sql.DB, maxRecords int) (int64, error) {
×
NEW
167
        ctx := context.Background()
×
NEW
168

×
NEW
169
        // Fetch from PostgreSQL (with limit if specified)
×
NEW
170
        query := `
×
NEW
171
                SELECT
×
NEW
172
                        object_id, created_at::text, ref, title, headline_image_url, gpx_url,
×
NEW
173
                        ST_X(_geoloc) as longitude, ST_Y(_geoloc) as latitude,
×
NEW
174
                        distance_km, description, video_url, display_address, postcode,
×
NEW
175
                        district, county, region, state, country, estimated_duration,
×
NEW
176
                        difficulty, array_to_json(terrain), array_to_json(points_of_interest),
×
NEW
177
                        array_to_json(facilities), route_type, array_to_json(activities)
×
NEW
178
                FROM routes
×
NEW
179
        `
×
NEW
180
        if maxRecords > 0 {
×
NEW
181
                query += fmt.Sprintf(" LIMIT %d", maxRecords)
×
NEW
182
        }
×
183

NEW
184
        rows, err := pgPool.Query(ctx, query)
×
NEW
185
        if err != nil {
×
NEW
186
                return 0, fmt.Errorf("failed to query PostgreSQL routes: %w", err)
×
NEW
187
        }
×
NEW
188
        defer rows.Close()
×
NEW
189

×
NEW
190
        // Prepare SQLite insert statement
×
NEW
191
        stmt, err := sqliteDB.Prepare(`
×
NEW
192
                INSERT INTO routes (
×
NEW
193
                        object_id, created_at, ref, title, headline_image_url, gpx_url,
×
NEW
194
                        latitude, longitude, distance_km, description, video_url,
×
NEW
195
                        display_address, postcode, district, county, region, state, country,
×
NEW
196
                        estimated_duration, difficulty, terrain, points_of_interest,
×
NEW
197
                        facilities, route_type, activities
×
NEW
198
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
×
NEW
199
        `)
×
NEW
200
        if err != nil {
×
NEW
201
                return 0, fmt.Errorf("failed to prepare SQLite statement: %w", err)
×
NEW
202
        }
×
NEW
203
        defer stmt.Close()
×
NEW
204

×
NEW
205
        // Begin transaction
×
NEW
206
        tx, err := sqliteDB.Begin()
×
NEW
207
        if err != nil {
×
NEW
208
                return 0, fmt.Errorf("failed to begin transaction: %w", err)
×
NEW
209
        }
×
210

NEW
211
        var count int64
×
NEW
212
        for rows.Next() {
×
NEW
213
                var objectID, ref, title, description, createdAt, estimatedDuration, difficulty string
×
NEW
214
                var headlineImageURL, gpxURL, videoURL, displayAddress, postcode sql.NullString
×
NEW
215
                var district, county, region, state, country, routeType sql.NullString
×
NEW
216
                var longitude, latitude, distanceKm sql.NullFloat64
×
NEW
217
                var terrain, poi, facilities, activities sql.NullString
×
NEW
218

×
NEW
219
                err := rows.Scan(
×
NEW
220
                        &objectID, &createdAt, &ref, &title, &headlineImageURL, &gpxURL,
×
NEW
221
                        &longitude, &latitude, &distanceKm, &description, &videoURL,
×
NEW
222
                        &displayAddress, &postcode, &district, &county, &region, &state,
×
NEW
223
                        &country, &estimatedDuration, &difficulty, &terrain, &poi,
×
NEW
224
                        &facilities, &routeType, &activities,
×
NEW
225
                )
×
NEW
226
                if err != nil {
×
NEW
227
                        return 0, fmt.Errorf("failed to scan row: %w", err)
×
NEW
228
                }
×
229

230
                // PostgreSQL array_to_json returns JSON arrays, but handle NULLs
NEW
231
                terrainJSON := "[]"
×
NEW
232
                if terrain.Valid {
×
NEW
233
                        terrainJSON = terrain.String
×
NEW
234
                }
×
NEW
235
                poiJSON := "[]"
×
NEW
236
                if poi.Valid {
×
NEW
237
                        poiJSON = poi.String
×
NEW
238
                }
×
NEW
239
                facilitiesJSON := "[]"
×
NEW
240
                if facilities.Valid {
×
NEW
241
                        facilitiesJSON = facilities.String
×
NEW
242
                }
×
NEW
243
                activitiesJSON := "[]"
×
NEW
244
                if activities.Valid {
×
NEW
245
                        activitiesJSON = activities.String
×
NEW
246
                }
×
247

248
                // Handle nullable fields
NEW
249
                headlineImageURLVal := ""
×
NEW
250
                if headlineImageURL.Valid {
×
NEW
251
                        headlineImageURLVal = headlineImageURL.String
×
NEW
252
                }
×
NEW
253
                gpxURLVal := ""
×
NEW
254
                if gpxURL.Valid {
×
NEW
255
                        gpxURLVal = gpxURL.String
×
NEW
256
                }
×
NEW
257
                videoURLVal := ""
×
NEW
258
                if videoURL.Valid {
×
NEW
259
                        videoURLVal = videoURL.String
×
NEW
260
                }
×
261

NEW
262
                var displayAddressVal, postcodeVal string
×
NEW
263
                if displayAddress.Valid {
×
NEW
264
                        displayAddressVal = displayAddress.String
×
NEW
265
                }
×
NEW
266
                if postcode.Valid {
×
NEW
267
                        postcodeVal = postcode.String
×
NEW
268
                }
×
269

NEW
270
                districtVal := ""
×
NEW
271
                if district.Valid {
×
NEW
272
                        districtVal = district.String
×
NEW
273
                }
×
NEW
274
                countyVal := ""
×
NEW
275
                if county.Valid {
×
NEW
276
                        countyVal = county.String
×
NEW
277
                }
×
NEW
278
                regionVal := ""
×
NEW
279
                if region.Valid {
×
NEW
280
                        regionVal = region.String
×
NEW
281
                }
×
NEW
282
                stateVal := ""
×
NEW
283
                if state.Valid {
×
NEW
284
                        stateVal = state.String
×
NEW
285
                }
×
NEW
286
                countryVal := ""
×
NEW
287
                if country.Valid {
×
NEW
288
                        countryVal = country.String
×
NEW
289
                }
×
NEW
290
                routeTypeVal := ""
×
NEW
291
                if routeType.Valid {
×
NEW
292
                        routeTypeVal = routeType.String
×
NEW
293
                }
×
294

295
                // Convert nullable floats to float64
NEW
296
                var latVal, lonVal, distVal float64
×
NEW
297
                if latitude.Valid {
×
NEW
298
                        latVal = latitude.Float64
×
NEW
299
                }
×
NEW
300
                if longitude.Valid {
×
NEW
301
                        lonVal = longitude.Float64
×
NEW
302
                }
×
NEW
303
                if distanceKm.Valid {
×
NEW
304
                        distVal = distanceKm.Float64
×
NEW
305
                }
×
306

NEW
307
                _, err = stmt.Exec(
×
NEW
308
                        objectID, createdAt, ref, title, headlineImageURLVal, gpxURLVal,
×
NEW
309
                        latVal, lonVal, distVal, description, videoURLVal,
×
NEW
310
                        displayAddressVal, postcodeVal, districtVal, countyVal, regionVal, stateVal,
×
NEW
311
                        countryVal, estimatedDuration, difficulty, terrainJSON, poiJSON,
×
NEW
312
                        facilitiesJSON, routeTypeVal, activitiesJSON,
×
NEW
313
                )
×
NEW
314
                if err != nil {
×
NEW
315
                        tx.Rollback()
×
NEW
316
                        return 0, fmt.Errorf("failed to insert route %s: %w", objectID, err)
×
NEW
317
                }
×
318

NEW
319
                count++
×
NEW
320
                if count%1000 == 0 {
×
NEW
321
                        log.Printf("  ⏳ Migrated %d routes...", count)
×
NEW
322
                }
×
323
        }
324

NEW
325
        if rows.Err() != nil {
×
NEW
326
                tx.Rollback()
×
NEW
327
                return 0, fmt.Errorf("query error: %w", rows.Err())
×
NEW
328
        }
×
329

NEW
330
        if err := tx.Commit(); err != nil {
×
NEW
331
                return 0, fmt.Errorf("failed to commit transaction: %w", err)
×
NEW
332
        }
×
333

NEW
334
        return count, nil
×
335
}
336

NEW
337
func migrateNearby(pgPool *pgxpool.Pool, sqliteDB *sql.DB) (int64, error) {
×
NEW
338
        ctx := context.Background()
×
NEW
339
        rows, err := pgPool.Query(ctx, "SELECT route_object_id, description, object_id, ref FROM nearby")
×
NEW
340
        if err != nil {
×
NEW
341
                return 0, err
×
NEW
342
        }
×
NEW
343
        defer rows.Close()
×
NEW
344

×
NEW
345
        stmt, err := sqliteDB.Prepare("INSERT INTO nearby (route_object_id, description, object_id, ref) VALUES (?, ?, ?, ?)")
×
NEW
346
        if err != nil {
×
NEW
347
                return 0, err
×
NEW
348
        }
×
NEW
349
        defer stmt.Close()
×
NEW
350

×
NEW
351
        var count int64
×
NEW
352
        for rows.Next() {
×
NEW
353
                var routeID, description, objectID, ref string
×
NEW
354
                if err := rows.Scan(&routeID, &description, &objectID, &ref); err != nil {
×
NEW
355
                        return 0, err
×
NEW
356
                }
×
NEW
357
                if _, err := stmt.Exec(routeID, description, objectID, ref); err != nil {
×
NEW
358
                        return 0, err
×
NEW
359
                }
×
NEW
360
                count++
×
361
        }
NEW
362
        return count, rows.Err()
×
363
}
364

NEW
365
func migrateImages(pgPool *pgxpool.Pool, sqliteDB *sql.DB) (int64, error) {
×
NEW
366
        ctx := context.Background()
×
NEW
367
        rows, err := pgPool.Query(ctx, "SELECT route_object_id, src, title, caption FROM images")
×
NEW
368
        if err != nil {
×
NEW
369
                return 0, err
×
NEW
370
        }
×
NEW
371
        defer rows.Close()
×
NEW
372

×
NEW
373
        stmt, err := sqliteDB.Prepare("INSERT INTO images (route_object_id, src, title, caption) VALUES (?, ?, ?, ?)")
×
NEW
374
        if err != nil {
×
NEW
375
                return 0, err
×
NEW
376
        }
×
NEW
377
        defer stmt.Close()
×
NEW
378

×
NEW
379
        var count int64
×
NEW
380
        for rows.Next() {
×
NEW
381
                var routeID, src, title, caption string
×
NEW
382
                if err := rows.Scan(&routeID, &src, &title, &caption); err != nil {
×
NEW
383
                        return 0, err
×
NEW
384
                }
×
NEW
385
                if _, err := stmt.Exec(routeID, src, title, caption); err != nil {
×
NEW
386
                        return 0, err
×
NEW
387
                }
×
NEW
388
                count++
×
389
        }
NEW
390
        return count, rows.Err()
×
391
}
392

NEW
393
func migrateDetails(pgPool *pgxpool.Pool, sqliteDB *sql.DB) (int64, error) {
×
NEW
394
        ctx := context.Background()
×
NEW
395
        rows, err := pgPool.Query(ctx, "SELECT route_object_id, subtitle, content FROM details")
×
NEW
396
        if err != nil {
×
NEW
397
                return 0, err
×
NEW
398
        }
×
NEW
399
        defer rows.Close()
×
NEW
400

×
NEW
401
        stmt, err := sqliteDB.Prepare("INSERT INTO details (route_object_id, subtitle, content) VALUES (?, ?, ?)")
×
NEW
402
        if err != nil {
×
NEW
403
                return 0, err
×
NEW
404
        }
×
NEW
405
        defer stmt.Close()
×
NEW
406

×
NEW
407
        var count int64
×
NEW
408
        for rows.Next() {
×
NEW
409
                var routeID, subtitle, content string
×
NEW
410
                if err := rows.Scan(&routeID, &subtitle, &content); err != nil {
×
NEW
411
                        return 0, err
×
NEW
412
                }
×
NEW
413
                if _, err := stmt.Exec(routeID, subtitle, content); err != nil {
×
NEW
414
                        return 0, err
×
NEW
415
                }
×
NEW
416
                count++
×
417
        }
NEW
418
        return count, rows.Err()
×
419
}
420

NEW
421
func validateMigration(pgPool *pgxpool.Pool, sqliteDB *sql.DB, expectedCount int64) error {
×
NEW
422
        var sqliteRouteCount int64
×
NEW
423
        err := sqliteDB.QueryRow("SELECT COUNT(*) FROM routes").Scan(&sqliteRouteCount)
×
NEW
424
        if err != nil {
×
NEW
425
                return fmt.Errorf("failed to get SQLite count: %w", err)
×
NEW
426
        }
×
427

NEW
428
        if expectedCount != sqliteRouteCount {
×
NEW
429
                return fmt.Errorf("route count mismatch: expected=%d, SQLite=%d", expectedCount, sqliteRouteCount)
×
NEW
430
        }
×
431

NEW
432
        log.Printf("✅ Record counts match: %d routes", sqliteRouteCount)
×
NEW
433
        return nil
×
434
}
435

436
// arrayToJSON converts PostgreSQL array string to JSON array
437
// Input: "{hiking,mountain}" → Output: "["hiking","mountain"]"
NEW
438
func arrayToJSON(pgArray string) string {
×
NEW
439
        if pgArray == "" || pgArray == "{}" {
×
NEW
440
                return "[]"
×
NEW
441
        }
×
442

443
        // Remove braces
NEW
444
        pgArray = pgArray[1 : len(pgArray)-1]
×
NEW
445

×
NEW
446
        // Split by comma and quote each element
×
NEW
447
        elements := make([]string, 0)
×
NEW
448
        var current string
×
NEW
449
        for _, char := range pgArray {
×
NEW
450
                if char == ',' {
×
NEW
451
                        if current != "" {
×
NEW
452
                                elements = append(elements, fmt.Sprintf(`"%s"`, current))
×
NEW
453
                                current = ""
×
NEW
454
                        }
×
NEW
455
                } else {
×
NEW
456
                        current += string(char)
×
NEW
457
                }
×
458
        }
NEW
459
        if current != "" {
×
NEW
460
                elements = append(elements, fmt.Sprintf(`"%s"`, current))
×
NEW
461
        }
×
462

463
        // Build JSON array
NEW
464
        jsonArray := "["
×
NEW
465
        for i, elem := range elements {
×
NEW
466
                jsonArray += elem
×
NEW
467
                if i < len(elements)-1 {
×
NEW
468
                        jsonArray += ","
×
NEW
469
                }
×
470
        }
NEW
471
        jsonArray += "]"
×
NEW
472

×
NEW
473
        return jsonArray
×
474
}
475

NEW
476
func maskConnStr(connStr string) string {
×
NEW
477
        // Mask password for logging
×
NEW
478
        if len(connStr) > 20 {
×
NEW
479
                return connStr[:10] + "***" + connStr[len(connStr)-10:]
×
NEW
480
        }
×
NEW
481
        return "***"
×
482
}
NEW
483
func createSchemaWithoutFTS5(db *sql.DB) error {
×
NEW
484
        // Create basic schema without FTS5 virtual table and triggers
×
NEW
485
        schema := `
×
NEW
486
-- SQLite3 Schema for gps-routes-api (without FTS5)
×
NEW
487
-- Basic functionality without full-text search
×
NEW
488

×
NEW
489
-- Enable required features
×
NEW
490
PRAGMA foreign_keys = ON;
×
NEW
491
PRAGMA journal_mode = WAL;
×
NEW
492

×
NEW
493
-- Main routes table
×
NEW
494
CREATE TABLE routes (
×
NEW
495
    object_id TEXT PRIMARY KEY,
×
NEW
496
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
×
NEW
497
    ref TEXT NOT NULL,
×
NEW
498
    title TEXT NOT NULL,
×
NEW
499
    headline_image_url TEXT,
×
NEW
500
    gpx_url TEXT,
×
NEW
501
    latitude REAL,
×
NEW
502
    longitude REAL,
×
NEW
503
    _geoloc BLOB,
×
NEW
504
    distance_km REAL,
×
NEW
505
    description TEXT NOT NULL,
×
NEW
506
    video_url TEXT,
×
NEW
507
    display_address TEXT,
×
NEW
508
    postcode TEXT,
×
NEW
509
    district TEXT,
×
NEW
510
    county TEXT,
×
NEW
511
    region TEXT,
×
NEW
512
    state TEXT,
×
NEW
513
    country TEXT,
×
NEW
514
    estimated_duration TEXT,
×
NEW
515
    difficulty TEXT,
×
NEW
516
    terrain JSON,
×
NEW
517
    points_of_interest JSON,
×
NEW
518
    facilities JSON,
×
NEW
519
    route_type TEXT,
×
NEW
520
    activities JSON,
×
NEW
521
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
×
NEW
522
);
×
NEW
523

×
NEW
524
-- Scalar field indexes (B-tree)
×
NEW
525
CREATE INDEX idx_routes_district ON routes (district);
×
NEW
526
CREATE INDEX idx_routes_county ON routes (county);
×
NEW
527
CREATE INDEX idx_routes_region ON routes (region);
×
NEW
528
CREATE INDEX idx_routes_country ON routes (country);
×
NEW
529
CREATE INDEX idx_routes_route_type ON routes (route_type);
×
NEW
530
CREATE INDEX idx_routes_difficulty ON routes (difficulty);
×
NEW
531

×
NEW
532
-- JSON expression indexes for array fields
×
NEW
533
CREATE INDEX idx_routes_terrain ON routes (json_extract(terrain, '$[0]'));
×
NEW
534
CREATE INDEX idx_routes_activities ON routes (json_extract(activities, '$[0]'));
×
NEW
535
CREATE INDEX idx_routes_facilities ON routes (json_extract(facilities, '$[0]'));
×
NEW
536

×
NEW
537
-- Spatial indexes (lat/lng B-tree for bounding box queries)
×
NEW
538
CREATE INDEX idx_routes_lat_lng ON routes (latitude, longitude);
×
NEW
539

×
NEW
540
-- Related tables
×
NEW
541
CREATE TABLE nearby (
×
NEW
542
    id INTEGER PRIMARY KEY AUTOINCREMENT,
×
NEW
543
    route_object_id TEXT NOT NULL REFERENCES routes(object_id) ON DELETE CASCADE,
×
NEW
544
    description TEXT,
×
NEW
545
    object_id TEXT,
×
NEW
546
    ref TEXT
×
NEW
547
);
×
NEW
548

×
NEW
549
CREATE TABLE images (
×
NEW
550
    id INTEGER PRIMARY KEY AUTOINCREMENT,
×
NEW
551
    route_object_id TEXT NOT NULL REFERENCES routes(object_id) ON DELETE CASCADE,
×
NEW
552
    src TEXT NOT NULL,
×
NEW
553
    title TEXT,
×
NEW
554
    caption TEXT
×
NEW
555
);
×
NEW
556

×
NEW
557
CREATE TABLE details (
×
NEW
558
    id INTEGER PRIMARY KEY AUTOINCREMENT,
×
NEW
559
    route_object_id TEXT NOT NULL REFERENCES routes(object_id) ON DELETE CASCADE,
×
NEW
560
    subtitle TEXT NOT NULL,
×
NEW
561
    content TEXT NOT NULL
×
NEW
562
);
×
NEW
563

×
NEW
564
-- Performance pragmas
×
NEW
565
PRAGMA cache_size = 10000;
×
NEW
566
PRAGMA synchronous = NORMAL;
×
NEW
567
PRAGMA temp_store = MEMORY;
×
NEW
568
        `
×
NEW
569

×
NEW
570
        if _, err := db.Exec(schema); err != nil {
×
NEW
571
                return fmt.Errorf("failed to create basic schema: %w", err)
×
NEW
572
        }
×
573

NEW
574
        return nil
×
575
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc