goatcounter_migration_test.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. package executor
  2. import (
  3. "testing"
  4. )
  5. // TestGoatCounterSQLiteMigrations runs the shapes of GoatCounter's SQLite
  6. // migrations that exercise the engine features added for the release-2.7 port:
  7. // JSON1 mutation/extraction, bitwise OR, and table-level UNIQUE ... ON CONFLICT
  8. // REPLACE.
  9. func TestGoatCounterSQLiteMigrations(t *testing.T) {
  10. _, schema, table := newTestDB(t)
  11. e := newExec(schema, table)
  12. execMust(t, e, `CREATE TABLE sites (
  13. site_id INTEGER PRIMARY KEY AUTOINCREMENT,
  14. settings TEXT NOT NULL DEFAULT '{}',
  15. user_defaults TEXT NOT NULL DEFAULT '{}'
  16. )`)
  17. execMust(t, e, `CREATE TABLE users (
  18. user_id INTEGER PRIMARY KEY AUTOINCREMENT,
  19. settings TEXT NOT NULL DEFAULT '{}'
  20. )`)
  21. execMust(t, e, `INSERT INTO sites (site_id, settings, user_defaults) VALUES
  22. (1, '{"public": 1, "collect": 0, "widgets": []}', '{"widgets": []}')`)
  23. execMust(t, e, `INSERT INTO users (user_id, settings) VALUES (1, '{"widgets": []}')`)
  24. // db/migrate/2021-06-27-1-public-sqlite.sql
  25. execMust(t, e, `UPDATE sites SET settings = json_set(settings, '$.public', 'public') WHERE json_extract(settings, '$.public') = 1`)
  26. execMust(t, e, `UPDATE sites SET settings = json_set(settings, '$.public', 'private') WHERE json_extract(settings, '$.public') = 0`)
  27. res := execMust(t, e, `SELECT json_extract(settings, '$.public') FROM sites WHERE site_id = 1`)
  28. if res.Rows[0][0] != "public" {
  29. t.Fatalf("public flag = %v, want public", res.Rows[0][0])
  30. }
  31. // db/migrate/2021-12-02-2-language-enable-sqlite.sql
  32. execMust(t, e, `UPDATE sites SET
  33. settings = json_replace(settings, '$.collect', json_extract(settings, '$.collect') | 64),
  34. user_defaults = json_replace(user_defaults, '$.widgets', json_insert(json_extract(user_defaults, '$.widgets'), '$[#]', json('{"n":"languages"}')))`)
  35. execMust(t, e, `UPDATE users SET
  36. settings = json_replace(settings, '$.widgets', json_insert(json_extract(settings, '$.widgets'), '$[#]', json('{"n":"languages"}')))`)
  37. res = execMust(t, e, `SELECT json_extract(settings, '$.collect') FROM sites WHERE site_id = 1`)
  38. if res.Rows[0][0] != int64(64) {
  39. t.Fatalf("collect flag = %v, want 64", res.Rows[0][0])
  40. }
  41. res = execMust(t, e, `SELECT json_extract(user_defaults, '$.widgets[0].n') FROM sites WHERE site_id = 1`)
  42. if res.Rows[0][0] != "languages" {
  43. t.Fatalf("widgets[0].n = %v, want languages", res.Rows[0][0])
  44. }
  45. // A GoatCounter stats table as created by 2022-01-13-1-unfk-sqlite.sql:
  46. // composite UNIQUE with ON CONFLICT REPLACE and no primary key.
  47. execMust(t, e, `CREATE TABLE hit_counts (
  48. site_id INTEGER NOT NULL,
  49. path_id INTEGER NOT NULL,
  50. hour TEXT NOT NULL,
  51. total INTEGER NOT NULL,
  52. CONSTRAINT "hit_counts#site_id#path_id#hour" UNIQUE(site_id, path_id, hour) ON CONFLICT REPLACE
  53. )`)
  54. execMust(t, e, `INSERT INTO hit_counts (site_id, path_id, hour, total) VALUES (1, 1, '2024-01-01 00:00:00', 5)`)
  55. execMust(t, e, `INSERT INTO hit_counts (site_id, path_id, hour, total) VALUES (1, 1, '2024-01-01 00:00:00', 9)`)
  56. res = execMust(t, e, `SELECT total FROM hit_counts WHERE site_id = 1 AND path_id = 1`)
  57. if res.RowCount != 1 || res.Rows[0][0] != int64(9) {
  58. t.Fatalf("hit_counts replace = %v rows %v", res.Rows, res.Rows)
  59. }
  60. }
  61. // TestGoatCounterMigrationRebuildShape covers the table-rebuild dance used by
  62. // 2021-12-09-1-email-reports-sqlite.sql and 2022-01-13-1-unfk-sqlite.sql:
  63. // create a replacement table, copy rows with INSERT ... SELECT, drop and rename,
  64. // then build indexes (including composite expression unique indexes).
  65. func TestGoatCounterMigrationRebuildShape(t *testing.T) {
  66. _, schema, table := newTestDB(t)
  67. e := newExec(schema, table)
  68. execMust(t, e, "CREATE TABLE users (user_id INTEGER PRIMARY KEY AUTOINCREMENT, site_id INTEGER, email TEXT, seen_updates_at TIMESTAMP)")
  69. execMust(t, e, "INSERT INTO users (site_id, email) VALUES (1, 'A@x.com'), (1, 'b@x.com'), (2, 'a@x.com')")
  70. execMust(t, e, `CREATE TABLE users2 (
  71. user_id INTEGER PRIMARY KEY AUTOINCREMENT,
  72. site_id INTEGER NOT NULL,
  73. email VARCHAR NOT NULL,
  74. last_report_at TIMESTAMP NOT NULL DEFAULT current_timestamp
  75. )`)
  76. execMust(t, e, `INSERT INTO users2 (user_id, site_id, email)
  77. SELECT user_id, site_id, email FROM users`)
  78. execMust(t, e, "DROP TABLE users")
  79. execMust(t, e, "ALTER TABLE users2 RENAME TO users")
  80. execMust(t, e, `CREATE INDEX "users#site_id" ON users(site_id)`)
  81. execMust(t, e, `CREATE UNIQUE INDEX "users#site_id#email" ON users(site_id, lower(email))`)
  82. res := execMust(t, e, "SELECT count(*) FROM users")
  83. if res.Rows[0][0] != int64(3) {
  84. t.Fatalf("copied row count = %v, want 3", res.Rows[0][0])
  85. }
  86. // Case-insensitive uniqueness across the composite expression index.
  87. if _, err := execSQL(e, "INSERT INTO users (site_id, email) VALUES (1, 'a@x.com')"); err == nil {
  88. t.Fatal("expected composite expression unique index to reject a duplicate")
  89. }
  90. }
  91. func TestGoatCounterInsertWithSelectMigrationShape(t *testing.T) {
  92. _, schema, table := newTestDB(t)
  93. e := newExec(schema, table)
  94. execMust(t, e, "CREATE TABLE old_sizes (size TEXT)")
  95. execMust(t, e, "CREATE TABLE sizes (width INTEGER, height INTEGER)")
  96. execMust(t, e, "INSERT INTO old_sizes VALUES ('10,20'), ('30,40')")
  97. execMust(t, e, `INSERT INTO sizes (width, height)
  98. WITH source AS (
  99. SELECT size FROM old_sizes GROUP BY size
  100. )
  101. SELECT
  102. CAST(substr(size, 1, instr(size, ',') - 1) AS INTEGER),
  103. CAST(substr(size, instr(size, ',') + 1) AS INTEGER)
  104. FROM source`)
  105. res := execMust(t, e, "SELECT width, height FROM sizes ORDER BY width")
  106. if res.RowCount != 2 || res.Rows[0][0] != int64(10) || res.Rows[1][1] != int64(40) {
  107. t.Fatalf("migrated sizes = %v", res.Rows)
  108. }
  109. }
  110. // TestGoatCounterDropSizesShape covers 2025-06-21-2-drop-sizes.sql, which adds a
  111. // column, backfills it with a correlated scalar subquery, drops a column, and
  112. // drops the source table.
  113. func TestGoatCounterDropSizesShape(t *testing.T) {
  114. _, schema, table := newTestDB(t)
  115. e := newExec(schema, table)
  116. execMust(t, e, "CREATE TABLE sizes (size_id INTEGER PRIMARY KEY, width INTEGER)")
  117. execMust(t, e, "CREATE TABLE hits (id INTEGER PRIMARY KEY, size_id INTEGER)")
  118. execMust(t, e, "INSERT INTO sizes VALUES (1, 480), (2, 720)")
  119. execMust(t, e, "INSERT INTO hits (id, size_id) VALUES (1, 1), (2, 2)")
  120. execMust(t, e, "ALTER TABLE hits ADD COLUMN width SMALLINT NULL")
  121. execMust(t, e, "UPDATE hits SET width = (SELECT width FROM sizes WHERE size_id = hits.size_id)")
  122. execMust(t, e, "ALTER TABLE hits DROP COLUMN size_id")
  123. execMust(t, e, "DROP TABLE sizes")
  124. res := execMust(t, e, "SELECT id, width FROM hits ORDER BY id")
  125. if res.Rows[0][1] != int64(480) || res.Rows[1][1] != int64(720) {
  126. t.Fatalf("backfilled widths = %v", res.Rows)
  127. }
  128. if _, err := execSQL(e, "SELECT size_id FROM hits"); err == nil {
  129. t.Fatal("size_id should have been dropped")
  130. }
  131. }
  132. func TestGoatCounterDropThenRenameColumnMigration(t *testing.T) {
  133. _, schema, table := newTestDB(t)
  134. e := newExec(schema, table)
  135. execMust(t, e, "CREATE TABLE hit_counts (id INTEGER PRIMARY KEY, total INTEGER, total_unique INTEGER)")
  136. execMust(t, e, "INSERT INTO hit_counts VALUES (1, 111, 222)")
  137. execMust(t, e, "ALTER TABLE hit_counts DROP COLUMN total")
  138. execMust(t, e, "ALTER TABLE hit_counts RENAME COLUMN total_unique TO total")
  139. res := execMust(t, e, "SELECT total FROM hit_counts WHERE id = 1")
  140. if res.Rows[0][0] != int64(222) {
  141. t.Fatalf("renamed total = %v, want 222", res.Rows[0][0])
  142. }
  143. }