@@ -74,71 +74,21 @@ func NewProvider(
7474 return nil , err
7575 }
7676
77- // v1 declared the email and phone_number columns of authorizer_users and
78- // authorizer_otps as UNIQUE. v2 keeps plain (non-unique) indexes and enforces
79- // uniqueness in the application layer (see AddUser/UpdateUser) so behaviour is
80- // identical across all supported databases and these fields can stay optional
81- // (email-only or phone-only signups).
77+ // Older Authorizer releases (< 2.3.0) declared the email and phone_number
78+ // columns of authorizer_users and authorizer_otps as UNIQUE. v2 keeps plain
79+ // (non-unique) indexes and enforces uniqueness in the application layer (see
80+ // AddUser/UpdateUser), so behaviour is identical across all supported
81+ // databases and these fields can stay optional (email-only / phone-only
82+ // signups).
8283 //
83- // On an upgraded SQL database those columns are still unique, so GORM
84- // AutoMigrate tries to DROP CONSTRAINT "uni_<table>_<col>" — a name that does
85- // not match what the database actually created — failing with SQLSTATE 42704
86- // ("constraint does not exist") and aborting the whole migration. Depending on
87- // the v1 release, the old uniqueness exists either as a CONSTRAINT
88- // (<table>_<col>_key) or as a standalone UNIQUE INDEX (idx_<table>_<col>), so
89- // we clear both forms before AutoMigrate. Non-fatal: fresh installs have
90- // nothing to drop.
91- staleUniqueColumns := []struct {
92- model any
93- table , col string
94- }{
95- {& schemas.User {}, "authorizer_users" , "email" },
96- {& schemas.User {}, "authorizer_users" , "phone_number" },
97- {& schemas.OTP {}, "authorizer_otps" , "email" },
98- {& schemas.OTP {}, "authorizer_otps" , "phone_number" },
99- }
100-
101- // 1) Drop stale unique CONSTRAINTs by their well-known names.
102- // "<table>_<col>_key" is the Postgres/MySQL default; "uni_<table>_<col>" is
103- // GORM's default unique-constraint name.
104- for _ , c := range staleUniqueColumns {
105- for _ , name := range []string {c .table + "_" + c .col + "_key" , "uni_" + c .table + "_" + c .col } {
106- if sqlDB .Migrator ().HasConstraint (c .model , name ) {
107- if dropErr := sqlDB .Migrator ().DropConstraint (c .model , name ); dropErr != nil {
108- deps .Log .Debug ().Err (dropErr ).Str ("constraint" , name ).Msg ("failed to drop stale unique constraint" )
109- }
110- }
111- }
112- }
113-
114- // 2) Drop stale standalone UNIQUE INDEXes on the same columns (e.g.
115- // idx_authorizer_otps_phone_number created by a v1 gorm:"uniqueIndex").
116- // This is name-agnostic: only single-column UNIQUE indexes on email /
117- // phone_number are removed, so the current schema's non-unique indexes are
118- // left intact and AutoMigrate recreates anything it needs as non-unique.
119- uniqueColTargets := map [string ]bool {"email" : true , "phone_number" : true }
120- for _ , model := range []any {& schemas.User {}, & schemas.OTP {}} {
121- indexes , idxErr := sqlDB .Migrator ().GetIndexes (model )
122- if idxErr != nil {
123- deps .Log .Debug ().Err (idxErr ).Msg ("failed to list indexes while clearing stale unique indexes" )
124- continue
125- }
126- for _ , idx := range indexes {
127- unique , ok := idx .Unique ()
128- if ! ok || ! unique {
129- continue
130- }
131- cols := idx .Columns ()
132- if len (cols ) != 1 || ! uniqueColTargets [cols [0 ]] {
133- continue
134- }
135- if dropErr := sqlDB .Migrator ().DropIndex (model , idx .Name ()); dropErr != nil {
136- // Constraint-backed indexes (handled in step 1) can't be dropped
137- // directly on Postgres — that's expected and harmless here.
138- deps .Log .Debug ().Err (dropErr ).Str ("index" , idx .Name ()).Msg ("failed to drop stale unique index" )
139- }
140- }
141- }
84+ // On an upgraded SQL database those columns are still unique, so GORM 1.25.x
85+ // AutoMigrate reconciles them by issuing DROP CONSTRAINT "uni_<table>_<col>"
86+ // — a fixed name (NamingStrategy.UniqueName) that rarely matches what the old
87+ // DB actually created (authorizer_users_email_key, idx_authorizer_otps_phone_number,
88+ // or any custom name) — failing with "constraint does not exist" (Postgres
89+ // SQLSTATE 42704) and aborting startup. Clear the legacy uniqueness up front,
90+ // name-agnostically, before AutoMigrate runs.
91+ clearLegacyColumnUniqueness (sqlDB , deps .Log )
14292
14393 err = sqlDB .AutoMigrate (& schemas.User {}, & schemas.VerificationRequest {}, & schemas.Session {}, & schemas.Env {}, & schemas.Webhook {}, & schemas.WebhookLog {}, & schemas.EmailTemplate {}, & schemas.OTP {}, & schemas.Authenticator {}, & schemas.SessionToken {}, & schemas.MFASession {}, & schemas.OAuthState {}, & schemas.AuditLog {})
14494 if err != nil {
@@ -189,3 +139,80 @@ func (p *provider) Close() error {
189139 }
190140 return sqlDB .Close ()
191141}
142+
143+ // clearLegacyColumnUniqueness removes any single-column UNIQUE constraint or
144+ // index on the email / phone_number columns of authorizer_users and
145+ // authorizer_otps, regardless of its name. See the call site in NewProvider for
146+ // why this must run before AutoMigrate. Best-effort and non-fatal: fresh
147+ // installs, and catalogs without information_schema (sqlite), simply find
148+ // nothing to drop.
149+ func clearLegacyColumnUniqueness (db * gorm.DB , log * zerolog.Logger ) {
150+ targets := map [string ]bool {"email" : true , "phone_number" : true }
151+ tables := []struct {
152+ model any
153+ name string
154+ }{
155+ {& schemas.User {}, "authorizer_users" },
156+ {& schemas.OTP {}, "authorizer_otps" },
157+ }
158+
159+ for _ , t := range tables {
160+ // 1) Drop single-column UNIQUE *constraints* by their real names, read
161+ // from the same catalog GORM uses (information_schema). This matches
162+ // ANY name — ..._key, uni_..., idx_..., or custom — which is what
163+ // actually prevents the DROP CONSTRAINT "uni_..." 42704 abort. A
164+ // constraint's backing index cannot be removed with DROP INDEX, so it
165+ // must go via DropConstraint.
166+ var rows []struct {
167+ ConstraintName string `gorm:"column:constraint_name"`
168+ ColumnName string `gorm:"column:column_name"`
169+ }
170+ const q = `SELECT tc.constraint_name AS constraint_name, kcu.column_name AS column_name
171+ FROM information_schema.table_constraints tc
172+ JOIN information_schema.key_column_usage kcu
173+ ON tc.constraint_name = kcu.constraint_name
174+ AND tc.table_schema = kcu.table_schema
175+ AND tc.table_name = kcu.table_name
176+ WHERE tc.constraint_type = 'UNIQUE' AND tc.table_name = ?`
177+ if err := db .Raw (q , t .name ).Scan (& rows ).Error ; err != nil {
178+ // sqlite and other catalogs without information_schema land here; they
179+ // are not affected by the GORM unique-reconciliation behaviour.
180+ log .Debug ().Err (err ).Str ("table" , t .name ).Msg ("skip legacy unique-constraint scan" )
181+ } else {
182+ columnsByConstraint := map [string ][]string {}
183+ for _ , r := range rows {
184+ columnsByConstraint [r .ConstraintName ] = append (columnsByConstraint [r .ConstraintName ], r .ColumnName )
185+ }
186+ for name , cols := range columnsByConstraint {
187+ if len (cols ) == 1 && targets [cols [0 ]] {
188+ if err := db .Migrator ().DropConstraint (t .model , name ); err != nil {
189+ log .Debug ().Err (err ).Str ("constraint" , name ).Msg ("failed to drop legacy unique constraint" )
190+ }
191+ }
192+ }
193+ }
194+
195+ // 2) Drop any standalone single-column UNIQUE *index* (CREATE UNIQUE
196+ // INDEX, not a constraint) on the same columns. These do not cause the
197+ // abort — GORM reads column-uniqueness from table_constraints only —
198+ // but removing them makes the column genuinely non-unique, matching the
199+ // v2 application-layer model. GetIndexes already excludes
200+ // constraint-backed indexes, so this only sees the standalone form;
201+ // AutoMigrate then recreates the plain non-unique search index.
202+ indexes , err := db .Migrator ().GetIndexes (t .model )
203+ if err != nil {
204+ log .Debug ().Err (err ).Str ("table" , t .name ).Msg ("skip legacy unique-index scan" )
205+ continue
206+ }
207+ for _ , idx := range indexes {
208+ if unique , ok := idx .Unique (); ! ok || ! unique {
209+ continue
210+ }
211+ if cols := idx .Columns (); len (cols ) == 1 && targets [cols [0 ]] {
212+ if err := db .Migrator ().DropIndex (t .model , idx .Name ()); err != nil {
213+ log .Debug ().Err (err ).Str ("index" , idx .Name ()).Msg ("failed to drop legacy unique index" )
214+ }
215+ }
216+ }
217+ }
218+ }
0 commit comments