Skip to content

Commit e72806f

Browse files
Merge pull request #61 from oracle-samples/tinglwan-fix-onupdate
Add support for 'OnUpdate'
2 parents 847d907 + c2a262c commit e72806f

File tree

6 files changed

+284
-6
lines changed

6 files changed

+284
-6
lines changed

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,49 @@ func main() {
3636
}
3737
```
3838

39+
## Documentation
40+
41+
### OnUpdate Foreign Key Constraint
42+
43+
Since Oracle doesn’t support `ON UPDATE` in foreign keys, the driver simulates it using **triggers**.
44+
45+
When a field has a constraint tagged with `OnUpdate`, the driver:
46+
47+
1. Skips generating the unsupported `ON UPDATE` clause in the foreign key definition.
48+
2. Creates a trigger on the parent table that automatically cascades updates to the child table(s) whenever the referenced column is changed.
49+
50+
The `OnUpdate` tag accepts the following values (case-insensitive): `CASCADE`, `SET NULL`, and `SET DEFAULT`.
51+
52+
Take the following struct for an example:
53+
54+
```go
55+
type Profile struct {
56+
ID uint
57+
Name string
58+
Refer uint
59+
}
60+
61+
type Member struct {
62+
ID uint
63+
Name string
64+
ProfileID uint
65+
Profile Profile `gorm:"Constraint:OnUpdate:CASCADE"`
66+
}
67+
```
68+
69+
Trigger SQL created by the driver when migrating:
70+
71+
```sql
72+
CREATE OR REPLACE TRIGGER "fk_trigger_profiles_id_members_profile_id"
73+
AFTER UPDATE OF "id" ON "profiles"
74+
FOR EACH ROW
75+
BEGIN
76+
UPDATE "members"
77+
SET "profile_id" = :NEW."id"
78+
WHERE "profile_id" = :OLD."id";
79+
END;
80+
```
81+
3982
## Contributing
4083

4184
This project welcomes contributions from the community. Before submitting a pull request, please [review our contribution guide](./CONTRIBUTING.md)

oracle/common.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,12 @@ func writeQuotedIdentifier(builder *strings.Builder, identifier string) {
424424
builder.WriteByte('"')
425425
}
426426

427+
func QuoteIdentifier(identifier string) string {
428+
var builder strings.Builder
429+
writeQuotedIdentifier(&builder, identifier)
430+
return builder.String()
431+
}
432+
427433
// writeTableRecordCollectionDecl writes the PL/SQL declarations needed to
428434
// define a custom record type and a collection of that record type,
429435
// based on the schema of the given table.

oracle/migrator.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,19 @@ func (m Migrator) CreateTable(values ...interface{}) error {
145145
}
146146
if constraint := rel.ParseConstraint(); constraint != nil {
147147
if constraint.Schema == stmt.Schema {
148+
// Oracle doesn’t support OnUpdate on foreign keys.
149+
// Use a trigger instead to propagate the update to the child table instead.
150+
if len(constraint.References) > 0 && constraint.OnUpdate != "" {
151+
defer func(tx *gorm.DB, table string, constraint *schema.Constraint, onUpdate string) {
152+
if err == nil {
153+
// retore the OnUpdate value
154+
constraint.OnUpdate = onUpdate
155+
err = m.createUpadateCascadeTrigger(tx, constraint)
156+
}
157+
}(tx, stmt.Table, constraint, constraint.OnUpdate)
158+
constraint.OnUpdate = ""
159+
}
160+
148161
// If the same set of foreign keys already references the parent column,
149162
// remove duplicates to avoid ORA-02274: duplicate referential constraint specifications
150163
var foreignKeys []string
@@ -399,6 +412,32 @@ func (m Migrator) ColumnTypes(value interface{}) ([]gorm.ColumnType, error) {
399412
return columnTypes, execErr
400413
}
401414

415+
// CreateConstraint creates constraint based on the given 'value' and 'name'
416+
func (m Migrator) CreateConstraint(value interface{}, name string) error {
417+
return m.RunWithValue(value, func(stmt *gorm.Statement) error {
418+
constraint, table := m.GuessConstraintInterfaceAndTable(stmt, name)
419+
if constraint != nil {
420+
if c, ok := constraint.(*schema.Constraint); ok {
421+
// Oracle doesn’t support OnUpdate on foreign keys.
422+
// Use a trigger instead to propagate the update to the child table instead.
423+
if len(c.References) > 0 && c.OnUpdate != "" {
424+
m.createUpadateCascadeTrigger(m.DB, c)
425+
c.OnUpdate = ""
426+
constraint = c
427+
}
428+
}
429+
430+
vars := []interface{}{clause.Table{Name: table}}
431+
if stmt.TableExpr != nil {
432+
vars[0] = stmt.TableExpr
433+
}
434+
sql, values := constraint.Build()
435+
return m.DB.Exec("ALTER TABLE ? ADD "+sql, append(vars, values...)...).Error
436+
}
437+
return nil
438+
})
439+
}
440+
402441
// HasConstraint checks whether the table for the given `value` contains the specified constraint `name`
403442
func (m Migrator) HasConstraint(value interface{}, name string) bool {
404443
var count int64
@@ -418,6 +457,33 @@ func (m Migrator) HasConstraint(value interface{}, name string) bool {
418457
return count > 0
419458
}
420459

460+
// DropConstraint drops constraint based on the given 'value' and 'name'
461+
func (m Migrator) DropConstraint(value interface{}, name string) error {
462+
if err := m.RunWithValue(value, func(stmt *gorm.Statement) error {
463+
464+
constraint, _ := m.GuessConstraintInterfaceAndTable(stmt, name)
465+
466+
if c, ok := constraint.(*schema.Constraint); ok && c != nil {
467+
if len(c.References) > 0 && c.OnUpdate != "" {
468+
for i, fk := range c.ForeignKeys {
469+
triggerName := m.FkTriggerName(
470+
c.ReferenceSchema.Table,
471+
c.References[i].DBName,
472+
c.Schema.Table,
473+
fk.DBName,
474+
)
475+
return m.DB.Exec("DROP TRIGGER ?", clause.Column{Name: triggerName}).Error
476+
}
477+
}
478+
}
479+
return nil
480+
}); err != nil {
481+
return err
482+
}
483+
484+
return m.Migrator.DropConstraint(value, name)
485+
}
486+
421487
// DropIndex drops the index with the specified `name` from the table associated with `value`
422488
func (m Migrator) DropIndex(value interface{}, name string) error {
423489
return m.RunWithValue(value, func(stmt *gorm.Statement) error {
@@ -569,3 +635,64 @@ func (m Migrator) isNumeric(s string) bool {
569635
_, err := strconv.ParseFloat(s, 64)
570636
return err == nil
571637
}
638+
639+
func (m Migrator) FkTriggerName(refTable string, refField string, table string, field string) string {
640+
return fmt.Sprintf("fk_trigger_%s_%s_%s_%s", refTable, refField, table, field)
641+
}
642+
643+
// Creates a trigger to cascade the update to the child table
644+
func (m Migrator) createUpadateCascadeTrigger(tx *gorm.DB, constraint *schema.Constraint) error {
645+
onUpdate := strings.TrimSpace(strings.ToLower(constraint.OnUpdate))
646+
if onUpdate != "cascade" && onUpdate != "set null" && onUpdate != "set default" {
647+
return nil
648+
}
649+
650+
parentTable := constraint.ReferenceSchema.Table
651+
quotedParentTable := QuoteIdentifier(parentTable)
652+
table := constraint.Schema.Table
653+
quotedTable := QuoteIdentifier(table)
654+
655+
for i, fk := range constraint.ForeignKeys {
656+
parentField := constraint.References[i].DBName
657+
quotedParentField := QuoteIdentifier(parentField)
658+
field := fk.DBName
659+
quotedField := QuoteIdentifier(field)
660+
triggerName := m.FkTriggerName(parentTable, parentField, table, field)
661+
quotedTriggerName := QuoteIdentifier(triggerName)
662+
663+
var updateValue string
664+
switch onUpdate {
665+
case "cascade":
666+
updateValue = ":NEW." + quotedParentField
667+
case "set null":
668+
updateValue = "NULL"
669+
case "set default":
670+
updateValue = "DEFAULT"
671+
}
672+
673+
plsql := fmt.Sprintf(
674+
`CREATE OR REPLACE TRIGGER %s
675+
AFTER UPDATE OF %s ON %s
676+
FOR EACH ROW
677+
BEGIN
678+
UPDATE %s
679+
SET %s = %s
680+
WHERE %s = :OLD.%s;
681+
END;`,
682+
quotedTriggerName,
683+
quotedParentField,
684+
quotedParentTable,
685+
quotedTable,
686+
quotedField,
687+
updateValue,
688+
quotedField,
689+
quotedParentField,
690+
)
691+
692+
if err := tx.Exec(plsql).Error; err != nil {
693+
return err
694+
}
695+
}
696+
697+
return nil
698+
}

tests/associations_test.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,6 @@ func TestAssociationNotNullClear(t *testing.T) {
112112
}
113113

114114
func TestForeignKeyConstraints(t *testing.T) {
115-
t.Skip()
116115
type Profile struct {
117116
ID uint
118117
Name string
@@ -121,7 +120,7 @@ func TestForeignKeyConstraints(t *testing.T) {
121120

122121
type Member struct {
123122
ID uint
124-
Refer uint `gorm:"uniqueIndex"`
123+
Refer uint `gorm:"unique"`
125124
Name string
126125
Profile Profile `gorm:"Constraint:OnUpdate:CASCADE,OnDelete:CASCADE;FOREIGNKEY:MemberID;References:Refer"`
127126
}
@@ -168,11 +167,10 @@ func TestForeignKeyConstraints(t *testing.T) {
168167
}
169168

170169
func TestForeignKeyConstraintsBelongsTo(t *testing.T) {
171-
t.Skip()
172170
type Profile struct {
173171
ID uint
174172
Name string
175-
Refer uint `gorm:"uniqueIndex"`
173+
Refer uint `gorm:"unique"`
176174
}
177175

178176
type Member struct {

tests/go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@ go 1.24.4
55
require gorm.io/gorm v1.30.0
66

77
require (
8-
github.com/oracle-samples/gorm-oracle v0.0.1
8+
github.com/godror/godror v0.49.0
9+
github.com/oracle-samples/gorm-oracle v0.1.0
910
github.com/stretchr/testify v1.10.0
1011
)
1112

1213
require (
1314
github.com/VictoriaMetrics/easyproto v0.1.4 // indirect
1415
github.com/davecgh/go-spew v1.1.1 // indirect
1516
github.com/go-logfmt/logfmt v0.6.0 // indirect
16-
github.com/godror/godror v0.49.0 // indirect
1717
github.com/godror/knownpb v0.3.0 // indirect
1818
github.com/jinzhu/inflection v1.0.0 // indirect
1919
github.com/jinzhu/now v1.1.5 // indirect

tests/migrate_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1721,3 +1721,107 @@ func TestAutoMigrateDecimal(t *testing.T) {
17211721
decimalColumnsTest[MigrateDecimalColumn, MigrateDecimalColumn2](t, expectedSql)
17221722
}
17231723
}
1724+
1725+
func TestMigrateOnUpdateConstraint(t *testing.T) {
1726+
type Owner struct {
1727+
ID int
1728+
Name string
1729+
}
1730+
1731+
type Pen1 struct {
1732+
gorm.Model
1733+
OwnerID int
1734+
Owner Owner `gorm:"constraint:OnUpdate:CASCADE;"`
1735+
}
1736+
1737+
type Pen2 struct {
1738+
gorm.Model
1739+
OwnerID int `gorm:"default: 18"`
1740+
Owner Owner `gorm:"constraint:OnUpdate:SET DEFAULT;"`
1741+
}
1742+
1743+
type Pen3 struct {
1744+
gorm.Model
1745+
OwnerID int
1746+
Owner Owner `gorm:"constraint:OnUpdate:SET NULL;"`
1747+
}
1748+
1749+
DB.Migrator().DropTable(&Owner{}, &Pen1{}, &Pen2{}, &Pen3{})
1750+
1751+
// Test 1: Verify the trigger is created using CreateTable()
1752+
if err := DB.Migrator().CreateTable(&Owner{}, &Pen1{}, &Pen2{}, &Pen3{}); err != nil {
1753+
t.Fatalf("Failed to create table, got error: %v", err)
1754+
}
1755+
1756+
triggerNames := []string{
1757+
"fk_trigger_owners_id_pen1_owner_id",
1758+
"fk_trigger_owners_id_pen2_owner_id",
1759+
"fk_trigger_owners_id_pen3_owner_id",
1760+
}
1761+
1762+
for _, triggerName := range triggerNames {
1763+
var count int
1764+
DB.Raw("SELECT count(*) FROM user_triggers where trigger_name = ?", triggerName).Scan(&count)
1765+
if count != 1 {
1766+
t.Errorf("Should find the trigger %s", triggerName)
1767+
}
1768+
}
1769+
1770+
// Test 2: Verify the trigger is created using CreateConstraint()
1771+
penStructs := []interface{}{&Pen1{}, &Pen2{}, &Pen3{}}
1772+
constraintNames := []string{"fk_pen1_owner", "fk_pen2_owner", "fk_pen3_owner"}
1773+
for i := range 3 {
1774+
if err := DB.Migrator().DropConstraint(penStructs[i], constraintNames[i]); err != nil {
1775+
t.Errorf("failed to drop constraint %v, got error %v", constraintNames[i], err)
1776+
}
1777+
1778+
if err := DB.Migrator().CreateConstraint(penStructs[i], constraintNames[i]); err != nil {
1779+
t.Errorf("failed to create constraint %v, got error %v", constraintNames[i], err)
1780+
}
1781+
1782+
var count int
1783+
DB.Raw("SELECT count(*) FROM user_triggers where trigger_name = ?", triggerNames[i]).Scan(&count)
1784+
if count != 1 {
1785+
t.Errorf("Should find the trigger %s", triggerNames[i])
1786+
}
1787+
}
1788+
1789+
// Test 3: Verify each trigger work
1790+
pen1 := Pen1{Owner: Owner{ID: 1, Name: "John"}}
1791+
DB.Create(&pen1)
1792+
DB.Model(pen1.Owner).Update("id", 100)
1793+
1794+
var updatedPen1 Pen1
1795+
if err := DB.First(&updatedPen1, "\"id\" = ?", pen1.ID).Error; err != nil {
1796+
panic(fmt.Errorf("failed to find member, got error: %v", err))
1797+
} else if updatedPen1.OwnerID != 100 {
1798+
panic(fmt.Errorf("company id is not equal: expects: %v, got: %v", 100, updatedPen1.OwnerID))
1799+
}
1800+
1801+
pen2 := Pen2{Owner: Owner{ID: 2, Name: "Mary"}}
1802+
DB.Create(&pen2)
1803+
// When the ID in the owners table is updated, the primary key in pen2 (owner_id column)
1804+
// is set to its default value (18). To avoid violating the foreign key constraint in pen2,
1805+
// we need to insert this record into the owners table in advance.
1806+
owner := Owner{ID: 18, Name: "MaryBackup"}
1807+
DB.Create(&owner)
1808+
DB.Model(pen2.Owner).Update("id", 200)
1809+
1810+
var updatedPen2 Pen2
1811+
if err := DB.First(&updatedPen2, "\"id\" = ?", pen2.ID).Error; err != nil {
1812+
panic(fmt.Errorf("failed to find member, got error: %v", err))
1813+
} else if updatedPen2.OwnerID != 18 {
1814+
panic(fmt.Errorf("company id is not equal: expects: %v, got: %v", 18, updatedPen2.OwnerID))
1815+
}
1816+
1817+
pen3 := Pen3{Owner: Owner{ID: 3, Name: "Jane"}}
1818+
DB.Create(&pen3)
1819+
DB.Model(pen3.Owner).Update("id", 300)
1820+
1821+
var updatedPen3 Pen3
1822+
if err := DB.First(&updatedPen3, "\"id\" = ?", pen3.ID).Error; err != nil {
1823+
panic(fmt.Errorf("failed to find member, got error: %v", err))
1824+
} else if updatedPen3.OwnerID != 0 {
1825+
panic(fmt.Errorf("company id is not equal: expects: %v, got: %v", 0, updatedPen3.OwnerID))
1826+
}
1827+
}

0 commit comments

Comments
 (0)