goatcounter_migration_test.go 8.3 KB

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