db-migrate-x 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,180 @@
1
+ # Drop Table
2
+
3
+ This guide explains how to remove database tables using `db-migrate`.
4
+
5
+ ## Basic Table Removal
6
+
7
+ Use `drop_table` to remove a table:
8
+
9
+ ```ruby
10
+ DB::Migrate.migrate("remove_old_table", client) do
11
+ drop_table :old_users_table
12
+ end
13
+ ```
14
+
15
+ ## Safe Table Removal
16
+
17
+ Use `if_exists` to avoid errors if the table doesn't exist:
18
+
19
+ ```ruby
20
+ DB::Migrate.migrate("safe_cleanup", client) do
21
+ drop_table :maybe_missing_table, if_exists: true
22
+ end
23
+ ```
24
+
25
+ ## Feature Detection
26
+
27
+ The gem automatically detects whether your database supports `IF EXISTS` clauses:
28
+
29
+ **PostgreSQL & MariaDB:**
30
+ ```sql
31
+ DROP TABLE IF EXISTS old_table;
32
+ ```
33
+
34
+ **Databases without IF EXISTS support:**
35
+ ```sql
36
+ DROP TABLE old_table;
37
+ ```
38
+
39
+ ## Multiple Table Removal
40
+
41
+ Remove multiple tables in a single migration:
42
+
43
+ ```ruby
44
+ DB::Migrate.migrate("cleanup_old_tables", client) do
45
+ drop_table :temp_users, if_exists: true
46
+ drop_table :old_analytics, if_exists: true
47
+ drop_table :deprecated_logs, if_exists: true
48
+ end
49
+ ```
50
+
51
+ ## Advanced Examples
52
+
53
+ ### Conditional Removal
54
+
55
+ Check if a table exists before dropping it:
56
+
57
+ ```ruby
58
+ DB::Migrate.migrate("conditional_cleanup", client) do
59
+ if information_schema.table_exists?(:old_table)
60
+ drop_table :old_table
61
+ end
62
+ end
63
+ ```
64
+
65
+ ### Table Replacement
66
+
67
+ Replace an existing table with a new structure:
68
+
69
+ ```ruby
70
+ DB::Migrate.migrate("replace_users_table", client) do
71
+ # Drop the old table
72
+ drop_table :users, if_exists: true
73
+
74
+ # Create the new table
75
+ create_table :users do
76
+ primary_key
77
+ column :name, "TEXT NOT NULL"
78
+ column :email, "TEXT UNIQUE NOT NULL"
79
+ timestamps
80
+ end
81
+ end
82
+ ```
83
+
84
+ ### Dependent Table Cleanup
85
+
86
+ Remove tables in the correct order to handle dependencies:
87
+
88
+ ```ruby
89
+ DB::Migrate.migrate("cleanup_related_tables", client) do
90
+ # Drop dependent tables first
91
+ drop_table :user_preferences, if_exists: true
92
+ drop_table :user_sessions, if_exists: true
93
+
94
+ # Then drop the main table
95
+ drop_table :users, if_exists: true
96
+ end
97
+ ```
98
+
99
+ ## Best Practices
100
+
101
+ ### Always Use if_exists for Cleanup
102
+
103
+ When removing tables during cleanup operations, always use `if_exists`:
104
+
105
+ ```ruby
106
+ # Good: Safe cleanup
107
+ drop_table :temp_table, if_exists: true
108
+
109
+ # Risky: May fail if table doesn't exist
110
+ drop_table :temp_table
111
+ ```
112
+
113
+ ### Document Destructive Operations
114
+
115
+ Add clear comments for destructive operations:
116
+
117
+ ```ruby
118
+ DB::Migrate.migrate("remove_deprecated_analytics", client) do
119
+ # WARNING: This permanently removes all analytics data from before 2023
120
+ # Ensure backup is completed before running this migration
121
+ drop_table :old_analytics_2022, if_exists: true
122
+ end
123
+ ```
124
+
125
+ ### Consider Data Migration
126
+
127
+ Before dropping tables with important data, consider migrating it:
128
+
129
+ ```ruby
130
+ DB::Migrate.migrate("migrate_user_data", client) do
131
+ # First, migrate important data
132
+ session.query("INSERT INTO users_new SELECT id, name, email FROM users_old")
133
+
134
+ # Then drop the old table
135
+ drop_table :users_old, if_exists: true
136
+ end
137
+ ```
138
+
139
+ ## Safety Considerations
140
+
141
+ ### Backup Important Data
142
+
143
+ Always backup important data before dropping tables:
144
+
145
+ ```bash
146
+ # PostgreSQL
147
+ pg_dump -t old_important_table mydb > backup.sql
148
+
149
+ # MariaDB/MySQL
150
+ mysqldump mydb old_important_table > backup.sql
151
+ ```
152
+
153
+ ### Test Migrations
154
+
155
+ Test destructive migrations on a copy of your production data:
156
+
157
+ ```ruby
158
+ # Test migration on development/staging first
159
+ DB::Migrate.migrate("test_table_removal", client) do
160
+ drop_table :test_table, if_exists: true
161
+ end
162
+ ```
163
+
164
+ ### Transaction Safety
165
+
166
+ Table drops are included in the migration transaction and will be rolled back if any subsequent operation fails:
167
+
168
+ ```ruby
169
+ DB::Migrate.migrate("safe_migration", client) do
170
+ drop_table :old_table, if_exists: true
171
+
172
+ create_table :new_table do
173
+ primary_key
174
+ column :name, "TEXT NOT NULL"
175
+ end
176
+
177
+ # If this fails, the table drop above is rolled back
178
+ create_index :new_table, :name
179
+ end
180
+ ```
@@ -0,0 +1,94 @@
1
+ # Getting Started
2
+
3
+ This guide explains how to get started with `db-migrate` for managing database schema changes in Ruby applications.
4
+
5
+ ## Installation
6
+
7
+ Add the gem to your project:
8
+
9
+ ```bash
10
+ $ bundle add db-migrate
11
+ ```
12
+
13
+ You'll also need a database adapter. For PostgreSQL:
14
+
15
+ ```bash
16
+ $ bundle add db-postgres
17
+ ```
18
+
19
+ For MariaDB/MySQL:
20
+
21
+ ```bash
22
+ $ bundle add db-mariadb
23
+ ```
24
+
25
+ ## Core Concepts
26
+
27
+ `db-migrate` provides a simple and flexible way to manage database schema changes:
28
+
29
+ - {DB::Migrate::Migration} which represents a single database migration with schema changes.
30
+ - Database-agnostic migration operations that work across PostgreSQL, MariaDB, and other supported databases.
31
+ - Feature detection that automatically uses the best SQL syntax for your database.
32
+
33
+ ## Usage
34
+
35
+ Create and run a migration:
36
+
37
+ ```ruby
38
+ require "db/migrate"
39
+ require "db/postgres" # or 'db/mariadb'
40
+
41
+ # Connect to your database
42
+ client = DB::Client.new(DB::Postgres::Adapter.new(
43
+ host: "localhost",
44
+ database: "myapp_development"
45
+ ))
46
+
47
+ # Define and run a migration
48
+ DB::Migrate.migrate("create_users_table", client) do
49
+ create_table :users do
50
+ primary_key
51
+ column :name, "TEXT NOT NULL"
52
+ column :email, "TEXT UNIQUE"
53
+ timestamps
54
+ end
55
+ end
56
+ ```
57
+
58
+ ### Running Multiple Operations
59
+
60
+ Migrations can include multiple operations:
61
+
62
+ ```ruby
63
+ DB::Migrate.migrate("update_users_schema", client) do
64
+ # Add new columns
65
+ alter_table :users do
66
+ add_column :age, "INTEGER"
67
+ add_column :active, "BOOLEAN DEFAULT TRUE"
68
+ end
69
+
70
+ # Create indexes
71
+ create_index :users, :email
72
+ create_index :users, [:name, :active]
73
+ end
74
+ ```
75
+
76
+ ### Conditional Operations
77
+
78
+ Use conditional operations when you're not sure if tables or columns exist:
79
+
80
+ ```ruby
81
+ DB::Migrate.migrate("safe_schema_update", client) do
82
+ # Only create table if it doesn't exist
83
+ create_table? :profiles do
84
+ primary_key
85
+ column :user_id, "BIGINT NOT NULL"
86
+ column :bio, "TEXT"
87
+ end
88
+
89
+ # Only drop table if it exists
90
+ drop_table :old_table, if_exists: true
91
+ end
92
+ ```
93
+
94
+ The `db-migrate` gem automatically detects your database's capabilities and only uses conditional operations (like `IF EXISTS`) when supported.
@@ -0,0 +1,29 @@
1
+ # Automatically generated context index for Utopia::Project guides.
2
+ # Do not edit then files in this directory directly, instead edit the guides and then run `bake utopia:project:agent:context:update`.
3
+ ---
4
+ description: Database migrations.
5
+ metadata:
6
+ documentation_uri: https://socketry.github.io/db-migrate/
7
+ funding_uri: https://github.com/sponsors/ioquatix/
8
+ source_code_uri: https://github.com/socketry/db-migrate.git
9
+ files:
10
+ - path: getting-started.md
11
+ title: Getting Started
12
+ description: This guide explains how to get started with `db-migrate` for managing
13
+ database schema changes in Ruby applications.
14
+ - path: migrations.md
15
+ title: Migrations
16
+ description: This guide explains how to create and structure database migrations
17
+ using `db-migrate`.
18
+ - path: create-table.md
19
+ title: Create Table
20
+ description: This guide explains how to create database tables using `db-migrate`.
21
+ - path: alter-table.md
22
+ title: Alter Table
23
+ description: This guide explains how to modify existing database tables using `db-migrate`.
24
+ - path: drop-table.md
25
+ title: Drop Table
26
+ description: This guide explains how to remove database tables using `db-migrate`.
27
+ - path: create-index.md
28
+ title: Create Index
29
+ description: This guide explains how to create database indexes using `db-migrate`.
@@ -0,0 +1,98 @@
1
+ # Migrations
2
+
3
+ This guide explains how to create and structure database migrations using `db-migrate`.
4
+
5
+ ## Overview
6
+
7
+ Migrations in `db-migrate` are Ruby blocks that define schema changes. Each migration runs inside a database transaction, ensuring consistency and allowing rollback on errors.
8
+
9
+ ## Basic Migration Structure
10
+
11
+ ```ruby
12
+ DB::Migrate.migrate("migration_name", client) do
13
+ # Schema operations go here
14
+ end
15
+ ```
16
+
17
+ ## Migration Naming
18
+
19
+ Use descriptive names that indicate what the migration does:
20
+
21
+ ```ruby
22
+ # Good examples
23
+ DB::Migrate.migrate("create_users_table", client) do
24
+ # ...
25
+ end
26
+
27
+ DB::Migrate.migrate("add_email_index_to_users", client) do
28
+ # ...
29
+ end
30
+
31
+ DB::Migrate.migrate("remove_deprecated_columns", client) do
32
+ # ...
33
+ end
34
+ ```
35
+
36
+ ## Transaction Safety
37
+
38
+ All migration operations run inside a database transaction. If any operation fails, the entire migration is rolled back:
39
+
40
+ ```ruby
41
+ DB::Migrate.migrate("complex_migration", client) do
42
+ create_table :users do
43
+ primary_key
44
+ column :name, "TEXT NOT NULL"
45
+ end
46
+
47
+ # If this fails, the table creation above is rolled back
48
+ create_index :users, :name
49
+ end
50
+ ```
51
+
52
+ ## Database Compatibility
53
+
54
+ `db-migrate` automatically detects your database's capabilities and generates appropriate SQL:
55
+
56
+ ### PostgreSQL
57
+ - Uses `BIGSERIAL` for auto-increment columns
58
+ - Supports `IF EXISTS` clauses
59
+ - Uses `ALTER COLUMN ... TYPE ... USING ...` for column type changes
60
+
61
+ ### MariaDB/MySQL
62
+ - Uses `BIGINT AUTO_INCREMENT` for auto-increment columns
63
+ - Supports `IF EXISTS` clauses
64
+ - Uses `MODIFY COLUMN` for column type changes
65
+
66
+ ## Available Operations
67
+
68
+ ### Table Operations
69
+ - `create_table(name)` - Create a new table
70
+ - `create_table?(name)` - Create table only if it doesn't exist
71
+ - `drop_table(name, if_exists: true)` - Drop a table
72
+ - `rename_table(old_name, new_name)` - Rename a table
73
+
74
+ ### Column Operations
75
+ - `add_column(name, type)` - Add a new column
76
+ - `drop_column(name, if_exists: true)` - Remove a column
77
+ - `rename_column(old_name, new_name)` - Rename a column
78
+ - `change_column(name, new_type)` - Change column type
79
+
80
+ ### Index Operations
81
+ - `create_index(table, columns)` - Create an index
82
+ - `drop_index(name, if_exists: true)` - Drop an index
83
+
84
+ ## Information Schema Access
85
+
86
+ Query database metadata within migrations:
87
+
88
+ ```ruby
89
+ DB::Migrate.migrate("conditional_migration", client) do
90
+ # Check if table exists before creating
91
+ unless information_schema.table_exists?(:users)
92
+ create_table :users do
93
+ primary_key
94
+ column :name, "TEXT NOT NULL"
95
+ end
96
+ end
97
+ end
98
+ ```
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2021-2024, by Samuel Williams.
5
+
6
+ module DB
7
+ module Migrate
8
+ class AlterTable
9
+ def initialize(name)
10
+ @name = name
11
+ @operations = []
12
+ end
13
+
14
+ attr_reader :name, :operations
15
+
16
+ # Add a new column to the table
17
+ def add_column(name, type, **options)
18
+ @operations << [:add_column, name, type, options]
19
+ end
20
+
21
+ # Drop a column from the table
22
+ def drop_column(name, if_exists: false)
23
+ @operations << [:drop_column, name, {if_exists: if_exists}]
24
+ end
25
+
26
+ # Rename a column
27
+ def rename_column(old_name, new_name)
28
+ @operations << [:rename_column, old_name, new_name, {}]
29
+ end
30
+
31
+ # Change column type or options
32
+ def change_column(name, type, **options)
33
+ @operations << [:change_column, name, type, options]
34
+ end
35
+
36
+ def call(session)
37
+ @operations.each do |operation, *args|
38
+ case operation
39
+ when :add_column
40
+ add_column_statement(session, *args)
41
+ when :drop_column
42
+ drop_column_statement(session, *args)
43
+ when :rename_column
44
+ rename_column_statement(session, *args)
45
+ when :change_column
46
+ change_column_statement(session, *args)
47
+ end
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def add_column_statement(session, column_name, type, options)
54
+ statement = session.clause("ALTER TABLE")
55
+ statement.identifier(@name)
56
+ statement.clause("ADD COLUMN")
57
+ statement.identifier(column_name)
58
+ statement.clause(type)
59
+
60
+ if options.key?(:null) && !options[:null]
61
+ statement.clause("NOT NULL")
62
+ end
63
+
64
+ if options.key?(:default)
65
+ statement.clause("DEFAULT")
66
+ statement.literal(options[:default])
67
+ end
68
+
69
+ if options[:unique]
70
+ statement.clause("UNIQUE")
71
+ end
72
+
73
+ Console.logger.info(self, statement)
74
+ statement.call
75
+ end
76
+
77
+ def drop_column_statement(session, column_name, options)
78
+ statement = session.clause("ALTER TABLE")
79
+ statement.identifier(@name)
80
+ statement.clause("DROP COLUMN")
81
+
82
+ # Use feature detection for IF EXISTS support
83
+ features = session.connection.features
84
+ if options[:if_exists] && features.conditional_operations?
85
+ statement.clause("IF EXISTS")
86
+ end
87
+
88
+ statement.identifier(column_name)
89
+
90
+ Console.logger.info(self, statement)
91
+ statement.call
92
+ end
93
+
94
+ def rename_column_statement(session, old_name, new_name, options)
95
+ statement = session.clause("ALTER TABLE")
96
+ statement.identifier(@name)
97
+ statement.clause("RENAME COLUMN")
98
+ statement.identifier(old_name)
99
+ statement.clause("TO")
100
+ statement.identifier(new_name)
101
+
102
+ Console.logger.info(self, statement)
103
+ statement.call
104
+ end
105
+
106
+ def change_column_statement(session, column_name, type, options)
107
+ # Use feature detection for database-specific syntax
108
+ features = session.connection.features
109
+
110
+ if features.modify_column?
111
+ # MySQL/MariaDB syntax: MODIFY COLUMN
112
+ statement = session.clause("ALTER TABLE")
113
+ statement.identifier(@name)
114
+ statement.clause("MODIFY COLUMN")
115
+ statement.identifier(column_name)
116
+ statement.clause(type)
117
+
118
+ Console.logger.info(self, statement)
119
+ statement.call
120
+ elsif features.alter_column_type?
121
+ # PostgreSQL syntax: ALTER COLUMN ... TYPE ... USING ...
122
+ statement = session.clause("ALTER TABLE")
123
+ statement.identifier(@name)
124
+ statement.clause("ALTER COLUMN")
125
+ statement.identifier(column_name)
126
+ statement.clause("TYPE")
127
+ statement.clause(type)
128
+
129
+ if features.using_clause?
130
+ # Add USING clause for safe conversion
131
+ statement.clause("USING")
132
+ statement.identifier(column_name)
133
+ statement.clause("::")
134
+ statement.clause(type)
135
+ end
136
+
137
+ Console.logger.info(self, statement)
138
+ statement.call
139
+ else
140
+ # Generic syntax for unsupported databases (default to PostgreSQL-style)
141
+ statement = session.clause("ALTER TABLE")
142
+ statement.identifier(@name)
143
+ statement.clause("ALTER COLUMN")
144
+ statement.identifier(column_name)
145
+ statement.clause("TYPE")
146
+ statement.clause(type)
147
+
148
+ Console.logger.info(self, statement)
149
+ statement.call
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -77,7 +77,7 @@ module DB
77
77
  else
78
78
  statement.clause(",")
79
79
  end
80
-
80
+
81
81
  if type == :key_column
82
82
  statement.clause(session.connection.key_column(name, **options))
83
83
  else
@@ -18,7 +18,9 @@ module DB
18
18
  def call(session)
19
19
  statement = session.clause("DROP INDEX")
20
20
 
21
- if @if_exists
21
+ # Use feature detection for IF EXISTS support
22
+ features = session.connection.features
23
+ if @if_exists && features.conditional_operations?
22
24
  statement.clause("IF EXISTS")
23
25
  end
24
26
 
@@ -20,7 +20,9 @@ module DB
20
20
  def call(session)
21
21
  statement = session.clause("DROP TABLE")
22
22
 
23
- if @if_exists
23
+ # Use feature detection for IF EXISTS support
24
+ features = session.connection.features
25
+ if @if_exists && features.conditional_operations?
24
26
  statement.clause("IF EXISTS")
25
27
  end
26
28
 
@@ -3,6 +3,8 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2021-2024, by Samuel Williams.
5
5
 
6
+ require "db"
7
+
6
8
  module DB
7
9
  module Migrate
8
10
  class InformationSchema
@@ -4,10 +4,12 @@
4
4
  # Copyright, 2021-2024, by Samuel Williams.
5
5
 
6
6
  require "async"
7
+ require "console"
7
8
 
8
9
  require_relative "create_table"
9
10
  require_relative "rename_table"
10
11
  require_relative "create_index"
12
+ require_relative "alter_table"
11
13
 
12
14
  module DB
13
15
  module Migrate
@@ -17,6 +19,8 @@ module DB
17
19
  @session = session
18
20
  end
19
21
 
22
+ attr_reader :name, :session
23
+
20
24
  def call(&block)
21
25
  create_table?(:migration) do
22
26
  primary_key
@@ -24,7 +28,19 @@ module DB
24
28
  timestamps
25
29
  end
26
30
 
31
+ # Check if migration has already been executed
32
+ if migration_exists?
33
+ Console.logger.info(self, "Migration '#{@name}' already executed, skipping...")
34
+ return
35
+ end
36
+
37
+ # Execute the migration
38
+ Console.logger.info(self, "Running migration '#{@name}'...")
27
39
  self.instance_eval(&block)
40
+
41
+ # Record successful migration
42
+ record_migration
43
+ Console.logger.info(self, "Migration '#{@name}' completed successfully.")
28
44
  end
29
45
 
30
46
  def information_schema
@@ -59,6 +75,45 @@ module DB
59
75
  drop_table = DropTable.new(name, if_exists: if_exists)
60
76
  drop_table.call(@session)
61
77
  end
78
+
79
+ def alter_table(name, &block)
80
+ alter_table = AlterTable.new(name)
81
+ alter_table.instance_eval(&block)
82
+ alter_table.call(@session)
83
+ end
84
+
85
+ private
86
+
87
+ # Check if this migration has already been executed
88
+ def migration_exists?
89
+ statement = @session.clause("SELECT COUNT(*) FROM")
90
+ statement.identifier(:migration)
91
+ statement.clause("WHERE")
92
+ statement.identifier(:name)
93
+ statement.clause("=")
94
+ statement.literal(@name)
95
+
96
+ result = statement.call
97
+ count = result.to_a.first.first
98
+ count > 0
99
+ end
100
+
101
+ # Record that this migration has been executed
102
+ def record_migration
103
+ statement = @session.clause("INSERT INTO")
104
+ statement.identifier(:migration)
105
+ statement.clause("(")
106
+ statement.identifier(:name)
107
+ statement.clause(",")
108
+ statement.identifier(:created_at)
109
+ statement.clause(",")
110
+ statement.identifier(:updated_at)
111
+ statement.clause(") VALUES (")
112
+ statement.literal(@name)
113
+ statement.clause(", NOW(), NOW())")
114
+
115
+ statement.call
116
+ end
62
117
  end
63
118
 
64
119
  def self.migrate(name, client, &block)
@@ -5,6 +5,6 @@
5
5
 
6
6
  module DB
7
7
  module Migrate
8
- VERSION = "0.2.0"
8
+ VERSION = "0.3.0"
9
9
  end
10
10
  end