Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions internal/diff/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"strings"

"github.com/pgschema/pgschema/internal/ir"
"github.com/pgschema/pgschema/internal/util"
)

// generateCreateIndexesSQL generates CREATE INDEX statements
Expand Down Expand Up @@ -76,7 +77,7 @@ func generateIndexSQLWithName(index *ir.Index, indexName string, targetSchema st
builder.WriteString("IF NOT EXISTS ")

// Index name
builder.WriteString(indexName)
builder.WriteString(util.QuoteIdentifier(indexName))
builder.WriteString(" ON ")

// Table name with proper schema qualification
Expand All @@ -100,8 +101,11 @@ func generateIndexSQLWithName(index *ir.Index, indexName string, targetSchema st
if strings.Contains(col.Name, "->>") || strings.Contains(col.Name, "->") {
// Use double parentheses for JSON expressions for clean format
builder.WriteString(fmt.Sprintf("((%s))", col.Name))
} else {
} else if strings.Contains(col.Name, "(") || strings.Contains(col.Name, ")") {
// Functional expressions like lower(column) should not be quoted
builder.WriteString(col.Name)
} else {
builder.WriteString(util.QuoteIdentifier(col.Name))
}

// Add direction if specified
Expand Down
174 changes: 174 additions & 0 deletions internal/diff/index_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package diff

import (
"strings"
"testing"

"github.com/pgschema/pgschema/internal/ir"
)

func TestGenerateIndexSQL_CamelCaseColumns(t *testing.T) {
tests := []struct {
name string
index *ir.Index
expected string
}{
{
name: "single camelCase column",
index: &ir.Index{
Name: "idx_invite_assignedTo",
Schema: "public",
Table: "invite",
Columns: []*ir.IndexColumn{
{Name: "assignedTo"},
},
},
expected: `CREATE INDEX IF NOT EXISTS "idx_invite_assignedTo" ON invite ("assignedTo");`,
},
{
name: "multiple camelCase columns",
index: &ir.Index{
Name: "idx_invite_composite",
Schema: "public",
Table: "invite",
Columns: []*ir.IndexColumn{
{Name: "createdAt"},
{Name: "invitedBy"},
},
},
expected: `CREATE INDEX IF NOT EXISTS idx_invite_composite ON invite ("createdAt", "invitedBy");`,
},
{
name: "mixed case and lowercase columns",
index: &ir.Index{
Name: "idx_mixed",
Schema: "public",
Table: "users",
Columns: []*ir.IndexColumn{
{Name: "firstName"},
{Name: "email"},
{Name: "lastName"},
},
},
expected: `CREATE INDEX IF NOT EXISTS idx_mixed ON users ("firstName", email, "lastName");`,
},
{
name: "unique index with camelCase",
index: &ir.Index{
Name: "uk_user_email",
Schema: "public",
Table: "user",
Type: ir.IndexTypeUnique,
Columns: []*ir.IndexColumn{
{Name: "emailAddress"},
},
},
expected: `CREATE UNIQUE INDEX IF NOT EXISTS uk_user_email ON "user" ("emailAddress");`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := generateIndexSQL(tt.index, "public", false)
if result != tt.expected {
t.Errorf("generateIndexSQL() = %q; want %q", result, tt.expected)
}
})
}
}

func TestGenerateIndexSQL_Concurrent_CamelCase(t *testing.T) {
index := &ir.Index{
Name: "idx_invite_assignedTo",
Schema: "public",
Table: "invite",
Columns: []*ir.IndexColumn{
{Name: "assignedTo"},
},
}

result := generateIndexSQL(index, "public", true)
expected := `CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_invite_assignedTo" ON invite ("assignedTo");`

if result != expected {
t.Errorf("generateIndexSQL(concurrent=true) = %q; want %q", result, expected)
}
}

func TestGenerateIndexSQL_ReservedWords(t *testing.T) {
index := &ir.Index{
Name: "idx_user_order",
Schema: "public",
Table: "user", // reserved word
Columns: []*ir.IndexColumn{
{Name: "order"}, // reserved word
{Name: "userId"},
},
}

result := generateIndexSQL(index, "public", false)

// Both "user" and "order" should be quoted as reserved words
if !strings.Contains(result, `"user"`) {
t.Errorf("Table name 'user' should be quoted")
}
if !strings.Contains(result, `"order"`) {
t.Errorf("Column name 'order' should be quoted")
}
}

func TestGenerateIndexSQL_FunctionalIndex(t *testing.T) {
tests := []struct {
name string
index *ir.Index
expected string
}{
{
name: "functional index with lower",
index: &ir.Index{
Name: "idx_users_lower_email",
Schema: "public",
Table: "users",
Columns: []*ir.IndexColumn{
{Name: "lower(email)"},
},
},
expected: `CREATE INDEX IF NOT EXISTS idx_users_lower_email ON users (lower(email));`,
},
{
name: "functional index with upper on camelCase column",
index: &ir.Index{
Name: "idx_users_upper_firstname",
Schema: "public",
Table: "users",
Columns: []*ir.IndexColumn{
{Name: "upper(firstName)"}, // firstName inside function should not be quoted
},
},
expected: `CREATE INDEX IF NOT EXISTS idx_users_upper_firstname ON users (upper(firstName));`,
},
{
name: "mixed functional and regular columns",
index: &ir.Index{
Name: "idx_mixed_func",
Schema: "public",
Table: "users",
Columns: []*ir.IndexColumn{
{Name: "lower(email)"},
{Name: "lastName"}, // regular camelCase should be quoted
{Name: "age"}, // regular lowercase should not be quoted
},
},
expected: `CREATE INDEX IF NOT EXISTS idx_mixed_func ON users (lower(email), "lastName", age);`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := generateIndexSQL(tt.index, "public", false)
if result != tt.expected {
t.Errorf("generateIndexSQL() = %q; want %q", result, tt.expected)
}
})
}
}
13 changes: 10 additions & 3 deletions internal/plan/rewrite.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/pgschema/pgschema/internal/diff"
"github.com/pgschema/pgschema/internal/ir"
"github.com/pgschema/pgschema/internal/util"
)

// RewriteStep represents a single step in a rewrite operation
Expand Down Expand Up @@ -292,7 +293,7 @@ func generateIndexSQL(index *ir.Index, isConcurrent bool) string {
sql.WriteString(" CONCURRENTLY")
}
sql.WriteString(" IF NOT EXISTS ")
sql.WriteString(index.Name)
sql.WriteString(util.QuoteIdentifier(index.Name))
sql.WriteString(" ON ")

tableName := getTableNameWithSchema(index.Schema, index.Table)
Expand All @@ -307,7 +308,13 @@ func generateIndexSQL(index *ir.Index, isConcurrent bool) string {

var columnParts []string
for _, col := range index.Columns {
part := col.Name
var part string
// Don't quote functional expressions
if strings.Contains(col.Name, "(") || strings.Contains(col.Name, ")") {
part = col.Name
} else {
part = util.QuoteIdentifier(col.Name)
}
if col.Direction != "" && col.Direction != "ASC" {
part += " " + col.Direction
}
Expand Down Expand Up @@ -340,7 +347,7 @@ func generateIndexWaitQueryWithName(indexName string) string {
FROM pg_class c
LEFT JOIN pg_index i ON c.oid = i.indexrelid
LEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid
WHERE c.relname = '%s';`, indexName)
WHERE lower(c.relname) = lower('%s');`, indexName)
}

// Helper functions
Expand Down
3 changes: 3 additions & 0 deletions testdata/diff/online/add_camelcase_index/diff.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
CREATE INDEX IF NOT EXISTS "idx_invite_assignedTo" ON invite ("assignedTo");

CREATE INDEX IF NOT EXISTS idx_invite_created_invited ON invite ("createdAt", "invitedBy");
9 changes: 9 additions & 0 deletions testdata/diff/online/add_camelcase_index/new.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
CREATE TABLE public.invite (
id text NOT NULL,
"invitedBy" text,
"assignedTo" text,
"createdAt" timestamp with time zone
);

CREATE INDEX "idx_invite_assignedTo" ON public.invite ("assignedTo");
CREATE INDEX idx_invite_created_invited ON public.invite ("createdAt", "invitedBy");
6 changes: 6 additions & 0 deletions testdata/diff/online/add_camelcase_index/old.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
CREATE TABLE public.invite (
id text NOT NULL,
"invitedBy" text,
"assignedTo" text,
"createdAt" timestamp with time zone
);
36 changes: 36 additions & 0 deletions testdata/diff/online/add_camelcase_index/plan.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"can_run_in_transaction": false,
"has_unsafe_operations": false,
"rewrite_steps": [
{
"sql": "CREATE INDEX CONCURRENTLY IF NOT EXISTS \"idx_invite_assignedTo\" ON invite (\"assignedTo\");",
"can_run_in_transaction": false
},
{
"sql": "SELECT \n COALESCE(i.indisvalid, false) as done,\n CASE \n WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total\n ELSE 0\n END as progress\nFROM pg_class c\nLEFT JOIN pg_index i ON c.oid = i.indexrelid\nLEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid\nWHERE lower(c.relname) = lower('idx_invite_assignedTo');",
"can_run_in_transaction": true,
"directive": {
"type": "wait",
"properties": {
"query": "SELECT \n COALESCE(i.indisvalid, false) as done,\n CASE \n WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total\n ELSE 0\n END as progress\nFROM pg_class c\nLEFT JOIN pg_index i ON c.oid = i.indexrelid\nLEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid\nWHERE lower(c.relname) = lower('idx_invite_assignedTo');",
"message": "Creating index idx_invite_assignedTo"
}
}
},
{
"sql": "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_invite_created_invited ON invite (\"createdAt\", \"invitedBy\");",
"can_run_in_transaction": false
},
{
"sql": "SELECT \n COALESCE(i.indisvalid, false) as done,\n CASE \n WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total\n ELSE 0\n END as progress\nFROM pg_class c\nLEFT JOIN pg_index i ON c.oid = i.indexrelid\nLEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid\nWHERE lower(c.relname) = lower('idx_invite_created_invited');",
"can_run_in_transaction": true,
"directive": {
"type": "wait",
"properties": {
"query": "SELECT \n COALESCE(i.indisvalid, false) as done,\n CASE \n WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total\n ELSE 0\n END as progress\nFROM pg_class c\nLEFT JOIN pg_index i ON c.oid = i.indexrelid\nLEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid\nWHERE lower(c.relname) = lower('idx_invite_created_invited');",
"message": "Creating index idx_invite_created_invited"
}
}
}
]
}
27 changes: 27 additions & 0 deletions testdata/diff/online/add_camelcase_index/plan.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_invite_assignedTo" ON invite ("assignedTo");

-- pgschema:wait
SELECT
COALESCE(i.indisvalid, false) as done,
CASE
WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total
ELSE 0
END as progress
FROM pg_class c
LEFT JOIN pg_index i ON c.oid = i.indexrelid
LEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid
WHERE lower(c.relname) = lower('idx_invite_assignedTo');

CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_invite_created_invited ON invite ("createdAt", "invitedBy");

-- pgschema:wait
SELECT
COALESCE(i.indisvalid, false) as done,
CASE
WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total
ELSE 0
END as progress
FROM pg_class c
LEFT JOIN pg_index i ON c.oid = i.indexrelid
LEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid
WHERE lower(c.relname) = lower('idx_invite_created_invited');
44 changes: 44 additions & 0 deletions testdata/diff/online/add_camelcase_index/plan.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
Plan: 1 to modify.

Summary by type:
tables: 1 to modify

Tables:
~ invite
+ idx_invite_assignedTo (index)
+ idx_invite_created_invited (index)

DDL to be executed:
--------------------------------------------------

-- Transaction Group #1
CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_invite_assignedTo" ON invite ("assignedTo");

-- Transaction Group #2
-- pgschema:wait
SELECT
COALESCE(i.indisvalid, false) as done,
CASE
WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total
ELSE 0
END as progress
FROM pg_class c
LEFT JOIN pg_index i ON c.oid = i.indexrelid
LEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid
WHERE lower(c.relname) = lower('idx_invite_assignedTo');

-- Transaction Group #3
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_invite_created_invited ON invite ("createdAt", "invitedBy");

-- Transaction Group #4
-- pgschema:wait
SELECT
COALESCE(i.indisvalid, false) as done,
CASE
WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total
ELSE 0
END as progress
FROM pg_class c
LEFT JOIN pg_index i ON c.oid = i.indexrelid
LEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid
WHERE lower(c.relname) = lower('idx_invite_created_invited');
2 changes: 1 addition & 1 deletion testdata/diff/online/add_composite_index/plan.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
{
"steps": [
{
"sql": "SELECT \n COALESCE(i.indisvalid, false) as done,\n CASE \n WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total\n ELSE 0\n END as progress\nFROM pg_class c\nLEFT JOIN pg_index i ON c.oid = i.indexrelid\nLEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid\nWHERE c.relname = 'idx_users_email_status';",
"sql": "SELECT \n COALESCE(i.indisvalid, false) as done,\n CASE \n WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total\n ELSE 0\n END as progress\nFROM pg_class c\nLEFT JOIN pg_index i ON c.oid = i.indexrelid\nLEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid\nWHERE lower(c.relname) = lower('idx_users_email_status');",
"directive": {
"type": "wait",
"message": "Creating index idx_users_email_status"
Expand Down
2 changes: 1 addition & 1 deletion testdata/diff/online/add_composite_index/plan.sql
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ SELECT
FROM pg_class c
LEFT JOIN pg_index i ON c.oid = i.indexrelid
LEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid
WHERE c.relname = 'idx_users_email_status';
WHERE lower(c.relname) = lower('idx_users_email_status');
Loading
Loading