database-model-generator 0.6.1 → 0.7.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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGES.md +8 -0
- data/MANIFEST.md +32 -4
- data/README.md +20 -6
- data/bin/dmg +96 -36
- data/database-model-generator.gemspec +4 -3
- data/docker/postgresql/Dockerfile +22 -0
- data/docker/postgresql/POSTGRESQL.md +278 -0
- data/docker/postgresql/README.md +116 -0
- data/docker/postgresql/docker-compose.yml +51 -0
- data/docker/postgresql/init-db.sql +101 -0
- data/docker/postgresql/test-Dockerfile +20 -0
- data/docker/postgresql/test.sh +18 -0
- data/lib/database_model_generator.rb +12 -1
- data/lib/postgresql/model/generator.rb +304 -0
- data/spec/postgresql_model_generator_spec.rb +325 -0
- data/spec/support/postgresql_connection.rb +71 -0
- data.tar.gz.sig +0 -0
- metadata +31 -8
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
require_relative '../../database_model_generator'
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require 'pg'
|
|
5
|
+
rescue LoadError
|
|
6
|
+
# pg not available - PostgreSQL support will be disabled
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module Postgresql
|
|
10
|
+
module Model
|
|
11
|
+
class Generator < DatabaseModel::Generator::Base
|
|
12
|
+
VERSION = DatabaseModel::Generator::VERSION
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def validate_connection(connection)
|
|
17
|
+
unless defined?(PG) && connection.is_a?(PG::Connection)
|
|
18
|
+
raise ArgumentError, "Connection must be a PG::Connection object. Install 'pg' gem for PostgreSQL support."
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def normalize_table_name(table)
|
|
23
|
+
table.downcase # PostgreSQL uses lowercase for table names by convention
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def check_table_exists(table)
|
|
27
|
+
begin
|
|
28
|
+
sql = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1"
|
|
29
|
+
result = @connection.exec_params(sql, [table.downcase])
|
|
30
|
+
row = result.first
|
|
31
|
+
result.clear
|
|
32
|
+
row && row['count'].to_i > 0
|
|
33
|
+
rescue => e
|
|
34
|
+
puts "Error checking table existence: #{e.message}"
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def get_column_info
|
|
40
|
+
begin
|
|
41
|
+
sql = <<~SQL
|
|
42
|
+
SELECT
|
|
43
|
+
column_name,
|
|
44
|
+
data_type,
|
|
45
|
+
character_maximum_length,
|
|
46
|
+
numeric_precision,
|
|
47
|
+
numeric_scale,
|
|
48
|
+
is_nullable,
|
|
49
|
+
column_default,
|
|
50
|
+
udt_name
|
|
51
|
+
FROM information_schema.columns
|
|
52
|
+
WHERE table_schema = 'public' AND table_name = $1
|
|
53
|
+
ORDER BY ordinal_position
|
|
54
|
+
SQL
|
|
55
|
+
|
|
56
|
+
result = @connection.exec_params(sql, [@table])
|
|
57
|
+
@column_info = []
|
|
58
|
+
|
|
59
|
+
result.each do |row|
|
|
60
|
+
@column_info << PostgresqlColumnInfo.new(row)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
result.clear
|
|
64
|
+
rescue => e
|
|
65
|
+
raise "Error retrieving column information: #{e.message}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def get_primary_keys
|
|
70
|
+
begin
|
|
71
|
+
sql = <<~SQL
|
|
72
|
+
SELECT a.attname AS column_name
|
|
73
|
+
FROM pg_index i
|
|
74
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
|
75
|
+
WHERE i.indrelid = $1::regclass AND i.indisprimary
|
|
76
|
+
ORDER BY a.attnum
|
|
77
|
+
SQL
|
|
78
|
+
|
|
79
|
+
result = @connection.exec_params(sql, [@table])
|
|
80
|
+
@primary_keys = []
|
|
81
|
+
|
|
82
|
+
result.each do |row|
|
|
83
|
+
@primary_keys << row['column_name']
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
result.clear
|
|
87
|
+
rescue => e
|
|
88
|
+
raise "Error retrieving primary keys: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def get_foreign_keys
|
|
93
|
+
begin
|
|
94
|
+
sql = <<~SQL
|
|
95
|
+
SELECT
|
|
96
|
+
kcu.column_name
|
|
97
|
+
FROM information_schema.table_constraints tc
|
|
98
|
+
JOIN information_schema.key_column_usage kcu
|
|
99
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
100
|
+
AND tc.table_schema = kcu.table_schema
|
|
101
|
+
WHERE tc.table_schema = 'public'
|
|
102
|
+
AND tc.table_name = $1
|
|
103
|
+
AND tc.constraint_type = 'FOREIGN KEY'
|
|
104
|
+
SQL
|
|
105
|
+
|
|
106
|
+
result = @connection.exec_params(sql, [@table])
|
|
107
|
+
@foreign_keys = []
|
|
108
|
+
|
|
109
|
+
result.each do |row|
|
|
110
|
+
@foreign_keys << row['column_name']
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
result.clear
|
|
114
|
+
rescue => e
|
|
115
|
+
raise "Error retrieving foreign keys: #{e.message}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def get_constraints
|
|
120
|
+
begin
|
|
121
|
+
sql = <<~SQL
|
|
122
|
+
SELECT
|
|
123
|
+
tc.constraint_name,
|
|
124
|
+
tc.constraint_type,
|
|
125
|
+
kcu.column_name,
|
|
126
|
+
cc.check_clause
|
|
127
|
+
FROM information_schema.table_constraints tc
|
|
128
|
+
LEFT JOIN information_schema.key_column_usage kcu
|
|
129
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
130
|
+
AND tc.table_schema = kcu.table_schema
|
|
131
|
+
LEFT JOIN information_schema.check_constraints cc
|
|
132
|
+
ON tc.constraint_name = cc.constraint_name
|
|
133
|
+
AND tc.constraint_schema = cc.constraint_schema
|
|
134
|
+
WHERE tc.table_schema = 'public'
|
|
135
|
+
AND tc.table_name = $1
|
|
136
|
+
SQL
|
|
137
|
+
|
|
138
|
+
result = @connection.exec_params(sql, [@table])
|
|
139
|
+
@constraints = []
|
|
140
|
+
|
|
141
|
+
result.each do |row|
|
|
142
|
+
@constraints << {
|
|
143
|
+
'CONSTRAINT_NAME' => row['constraint_name'],
|
|
144
|
+
'CONSTRAINT_TYPE' => row['constraint_type'],
|
|
145
|
+
'COLUMN_NAME' => row['column_name'],
|
|
146
|
+
'CHECK_CLAUSE' => row['check_clause']
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
result.clear
|
|
151
|
+
rescue => e
|
|
152
|
+
raise "Error retrieving constraints: #{e.message}"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def get_dependencies
|
|
157
|
+
begin
|
|
158
|
+
sql = <<~SQL
|
|
159
|
+
SELECT
|
|
160
|
+
dependent_ns.nspname AS dependent_schema,
|
|
161
|
+
dependent_view.relname AS dependent_view,
|
|
162
|
+
source_table.relname AS source_table
|
|
163
|
+
FROM pg_depend
|
|
164
|
+
JOIN pg_rewrite ON pg_depend.objid = pg_rewrite.oid
|
|
165
|
+
JOIN pg_class AS dependent_view ON pg_rewrite.ev_class = dependent_view.oid
|
|
166
|
+
JOIN pg_class AS source_table ON pg_depend.refobjid = source_table.oid
|
|
167
|
+
JOIN pg_namespace dependent_ns ON dependent_ns.oid = dependent_view.relnamespace
|
|
168
|
+
WHERE source_table.relname = $1
|
|
169
|
+
AND dependent_ns.nspname = 'public'
|
|
170
|
+
AND source_table.relname != dependent_view.relname
|
|
171
|
+
SQL
|
|
172
|
+
|
|
173
|
+
result = @connection.exec_params(sql, [@table])
|
|
174
|
+
@dependencies = []
|
|
175
|
+
|
|
176
|
+
result.each do |row|
|
|
177
|
+
@dependencies << {
|
|
178
|
+
'NAME' => row['dependent_view'],
|
|
179
|
+
'TYPE' => 'VIEW'
|
|
180
|
+
}
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
result.clear
|
|
184
|
+
rescue => e
|
|
185
|
+
# Dependencies query may fail on some PostgreSQL versions, continue without error
|
|
186
|
+
@dependencies = []
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def format_constraint_type(constraint)
|
|
191
|
+
case constraint['CONSTRAINT_TYPE']
|
|
192
|
+
when 'PRIMARY KEY' then 'Primary Key'
|
|
193
|
+
when 'FOREIGN KEY' then 'Foreign Key'
|
|
194
|
+
when 'UNIQUE' then 'Unique'
|
|
195
|
+
when 'CHECK' then 'Check'
|
|
196
|
+
else constraint['CONSTRAINT_TYPE']
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def get_constraint_column_name(constraint)
|
|
201
|
+
constraint['COLUMN_NAME']
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def get_constraint_text(constraint)
|
|
205
|
+
constraint['CHECK_CLAUSE']
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def is_string_type?(column)
|
|
209
|
+
column.data_type.downcase =~ /(char|varchar|character varying|text|bpchar)/
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def is_date_type?(column)
|
|
213
|
+
column.data_type.downcase =~ /(date|time|timestamp)/
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def is_text_type?(column)
|
|
217
|
+
column.data_type.downcase =~ /(varchar|character varying|text|char)/
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def get_column_size(column)
|
|
221
|
+
column.character_maximum_length
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def build_full_text_index_sql(column)
|
|
225
|
+
"CREATE INDEX idx_#{@table.downcase}_#{column.name.downcase}_text ON #{@table.downcase} USING GIN (to_tsvector('english', #{column.name.downcase}))"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def get_full_text_index_type
|
|
229
|
+
"PostgreSQL Full-Text Search (GIN Index)"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def find_fk_table(fk)
|
|
233
|
+
# PostgreSQL-specific logic for finding referenced table
|
|
234
|
+
# Try to get actual referenced table from foreign key constraints
|
|
235
|
+
begin
|
|
236
|
+
sql = <<~SQL
|
|
237
|
+
SELECT
|
|
238
|
+
ccu.table_name AS referenced_table
|
|
239
|
+
FROM information_schema.table_constraints tc
|
|
240
|
+
JOIN information_schema.key_column_usage kcu
|
|
241
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
242
|
+
AND tc.table_schema = kcu.table_schema
|
|
243
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
244
|
+
ON tc.constraint_name = ccu.constraint_name
|
|
245
|
+
AND tc.table_schema = ccu.table_schema
|
|
246
|
+
WHERE tc.table_schema = 'public'
|
|
247
|
+
AND tc.table_name = $1
|
|
248
|
+
AND tc.constraint_type = 'FOREIGN KEY'
|
|
249
|
+
AND kcu.column_name = $2
|
|
250
|
+
SQL
|
|
251
|
+
|
|
252
|
+
result = @connection.exec_params(sql, [@table, fk])
|
|
253
|
+
row = result.first
|
|
254
|
+
if row && row['referenced_table']
|
|
255
|
+
result.clear
|
|
256
|
+
return row['referenced_table'].downcase
|
|
257
|
+
end
|
|
258
|
+
result.clear
|
|
259
|
+
rescue => e
|
|
260
|
+
# Fall back to naming convention if query fails
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Fallback to naming convention
|
|
264
|
+
fk.gsub(/_id$/i, '').pluralize rescue "#{fk.gsub(/_id$/i, '')}s"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def disconnect
|
|
268
|
+
@connection.close if @connection && !@connection.finished?
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# PostgreSQL column info wrapper
|
|
273
|
+
class PostgresqlColumnInfo
|
|
274
|
+
attr_reader :name, :data_type, :character_maximum_length, :numeric_precision, :numeric_scale, :nullable, :column_default, :udt_name
|
|
275
|
+
|
|
276
|
+
def initialize(row)
|
|
277
|
+
@name = row['column_name']
|
|
278
|
+
@data_type = row['data_type']
|
|
279
|
+
@character_maximum_length = row['character_maximum_length']&.to_i
|
|
280
|
+
@numeric_precision = row['numeric_precision']&.to_i
|
|
281
|
+
@numeric_scale = row['numeric_scale']&.to_i
|
|
282
|
+
@nullable = row['is_nullable'] == 'YES'
|
|
283
|
+
@column_default = row['column_default']
|
|
284
|
+
@udt_name = row['udt_name'] # PostgreSQL-specific user-defined type name
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def nullable?
|
|
288
|
+
@nullable
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def data_size
|
|
292
|
+
@character_maximum_length
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def precision
|
|
296
|
+
@numeric_precision
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def scale
|
|
300
|
+
@numeric_scale
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'support/postgresql_connection'
|
|
3
|
+
require 'postgresql/model/generator'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Postgresql::Model::Generator do
|
|
6
|
+
include_context 'PostgreSQL connection'
|
|
7
|
+
|
|
8
|
+
let(:generator) { described_class.new(connection) }
|
|
9
|
+
|
|
10
|
+
describe 'class information' do
|
|
11
|
+
it 'has the correct version number' do
|
|
12
|
+
expect(Postgresql::Model::Generator::VERSION).to eq('0.6.1')
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
describe '#initialize' do
|
|
17
|
+
it 'accepts a PG::Connection object' do
|
|
18
|
+
expect { described_class.new(connection) }.not_to raise_error
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'sets up instance variables correctly' do
|
|
22
|
+
expect(generator.connection).to eq(connection)
|
|
23
|
+
expect(generator.constraints).to eq([])
|
|
24
|
+
expect(generator.primary_keys).to eq([])
|
|
25
|
+
expect(generator.foreign_keys).to eq([])
|
|
26
|
+
expect(generator.dependencies).to eq([])
|
|
27
|
+
expect(generator.belongs_to).to eq([])
|
|
28
|
+
expect(generator.column_info).to eq([])
|
|
29
|
+
expect(generator.table).to be_nil
|
|
30
|
+
expect(generator.model).to be_nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'raises an error if connection is nil' do
|
|
34
|
+
expect { described_class.new(nil) }.to raise_error(ArgumentError, /Connection cannot be nil/)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'raises an error if connection is not a PG::Connection' do
|
|
38
|
+
expect { described_class.new("not a connection") }.to raise_error(ArgumentError, /must be a PG::Connection/)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
describe '#generate' do
|
|
43
|
+
it 'responds to the generate method' do
|
|
44
|
+
expect(generator).to respond_to(:generate)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'works with a table name' do
|
|
48
|
+
expect { generator.generate('users') }.not_to raise_error
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'raises an error for nil table name' do
|
|
52
|
+
expect { generator.generate(nil) }.to raise_error(ArgumentError, /Table name cannot be nil/)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'raises an error for empty table name' do
|
|
56
|
+
expect { generator.generate('') }.to raise_error(ArgumentError, /Table name cannot be nil/)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
context 'when generating from the users table' do
|
|
60
|
+
before { generator.generate('users') }
|
|
61
|
+
|
|
62
|
+
it 'sets the table name in lowercase' do
|
|
63
|
+
expect(generator.table).to eq('users')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'generates a model name from the table name' do
|
|
67
|
+
expect(generator.model).to eq('User')
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'sets view to false' do
|
|
71
|
+
expect(generator.view).to be false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'populates column information' do
|
|
75
|
+
expect(generator.column_info).to be_an(Array)
|
|
76
|
+
expect(generator.column_info).not_to be_empty
|
|
77
|
+
|
|
78
|
+
column_names = generator.column_info.map(&:name)
|
|
79
|
+
expect(column_names).to include('id')
|
|
80
|
+
expect(column_names).to include('username')
|
|
81
|
+
expect(column_names).to include('email')
|
|
82
|
+
expect(column_names).to include('status')
|
|
83
|
+
expect(column_names).to include('role')
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'finds primary keys' do
|
|
87
|
+
expect(generator.primary_keys).to be_an(Array)
|
|
88
|
+
expect(generator.primary_keys).to include('id')
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it 'populates constraints information' do
|
|
92
|
+
expect(generator.constraints).to be_an(Array)
|
|
93
|
+
expect(generator.constraints).not_to be_empty
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it 'detects enum columns' do
|
|
97
|
+
expect(generator.has_enum_columns?).to be true
|
|
98
|
+
enum_column_names = generator.enum_column_names
|
|
99
|
+
expect(enum_column_names).to include('status')
|
|
100
|
+
expect(enum_column_names).to include('role')
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'extracts enum values from constraints' do
|
|
104
|
+
generator.generate('users')
|
|
105
|
+
status_enum = generator.enum_columns.find { |e| e[:name] == 'status' }
|
|
106
|
+
expect(status_enum).not_to be_nil
|
|
107
|
+
expect(status_enum[:values]).to include('active', 'inactive', 'suspended', 'deleted')
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
context 'when generating from the posts table' do
|
|
112
|
+
before { generator.generate('posts') }
|
|
113
|
+
|
|
114
|
+
it 'sets the table name in lowercase' do
|
|
115
|
+
expect(generator.table).to eq('posts')
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'generates the correct model name' do
|
|
119
|
+
expect(generator.model).to eq('Post')
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'finds foreign keys' do
|
|
123
|
+
expect(generator.foreign_keys).to be_an(Array)
|
|
124
|
+
expect(generator.foreign_keys).to include('user_id')
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'identifies belongs_to associations' do
|
|
128
|
+
expect(generator.belongs_to).to be_an(Array)
|
|
129
|
+
expect(generator.belongs_to).to include('users')
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'detects multiple enum columns' do
|
|
133
|
+
enum_column_names = generator.enum_column_names
|
|
134
|
+
expect(enum_column_names).to include('status')
|
|
135
|
+
expect(enum_column_names).to include('priority')
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it 'extracts enum values for status' do
|
|
139
|
+
status_enum = generator.enum_columns.find { |e| e[:name] == 'status' }
|
|
140
|
+
expect(status_enum).not_to be_nil
|
|
141
|
+
expect(status_enum[:values]).to include('draft', 'published', 'archived')
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it 'extracts enum values for priority' do
|
|
145
|
+
priority_enum = generator.enum_columns.find { |e| e[:name] == 'priority' }
|
|
146
|
+
expect(priority_enum).not_to be_nil
|
|
147
|
+
expect(priority_enum[:values]).to include('low', 'medium', 'high', 'urgent')
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
context 'when generating from the comments table' do
|
|
152
|
+
before { generator.generate('comments') }
|
|
153
|
+
|
|
154
|
+
it 'detects polymorphic associations' do
|
|
155
|
+
expect(generator.has_polymorphic_associations?).to be true
|
|
156
|
+
expect(generator.polymorphic_association_names).to include('commentable')
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it 'identifies polymorphic foreign keys and types' do
|
|
160
|
+
poly_assoc = generator.polymorphic_associations.first
|
|
161
|
+
expect(poly_assoc[:foreign_key]).to eq('commentable_id')
|
|
162
|
+
expect(poly_assoc[:foreign_type]).to eq('commentable_type')
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
context 'when generating from the post_tags table' do
|
|
167
|
+
before { generator.generate('post_tags') }
|
|
168
|
+
|
|
169
|
+
it 'detects composite primary keys' do
|
|
170
|
+
expect(generator.primary_keys).to be_an(Array)
|
|
171
|
+
expect(generator.primary_keys.length).to be >= 2
|
|
172
|
+
expect(generator.primary_keys).to include('post_id')
|
|
173
|
+
expect(generator.primary_keys).to include('tag_id')
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it 'identifies multiple foreign keys' do
|
|
177
|
+
expect(generator.foreign_keys).to include('post_id')
|
|
178
|
+
expect(generator.foreign_keys).to include('tag_id')
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
describe '#table_exists?' do
|
|
184
|
+
it 'returns true for existing tables' do
|
|
185
|
+
generator.generate('users')
|
|
186
|
+
expect(generator.table_exists?).to be true
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
it 'returns false for non-existing tables' do
|
|
190
|
+
generator.generate('nonexistent_table')
|
|
191
|
+
expect(generator.table_exists?).to be false
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
describe '#generated?' do
|
|
196
|
+
it 'returns false before generate is called' do
|
|
197
|
+
expect(generator.generated?).to be false
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it 'returns true after successful generation' do
|
|
201
|
+
generator.generate('users')
|
|
202
|
+
expect(generator.generated?).to be true
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
describe '#column_names' do
|
|
207
|
+
it 'returns an array of column names' do
|
|
208
|
+
generator.generate('users')
|
|
209
|
+
column_names = generator.column_names
|
|
210
|
+
expect(column_names).to be_an(Array)
|
|
211
|
+
expect(column_names).to include('id', 'username', 'email')
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
describe '#constraint_summary' do
|
|
216
|
+
it 'returns a hash of constraints by column' do
|
|
217
|
+
generator.generate('users')
|
|
218
|
+
summary = generator.constraint_summary
|
|
219
|
+
expect(summary).to be_a(Hash)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
describe '#index_recommendations' do
|
|
224
|
+
it 'returns index recommendations' do
|
|
225
|
+
generator.generate('posts')
|
|
226
|
+
recommendations = generator.index_recommendations
|
|
227
|
+
|
|
228
|
+
expect(recommendations).to be_a(Hash)
|
|
229
|
+
expect(recommendations).to have_key(:foreign_keys)
|
|
230
|
+
expect(recommendations).to have_key(:date_queries)
|
|
231
|
+
expect(recommendations).to have_key(:status_enum)
|
|
232
|
+
expect(recommendations).to have_key(:composite)
|
|
233
|
+
expect(recommendations).to have_key(:full_text)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
it 'recommends indexes for foreign keys' do
|
|
237
|
+
generator.generate('posts')
|
|
238
|
+
recommendations = generator.index_recommendations
|
|
239
|
+
|
|
240
|
+
fk_recommendations = recommendations[:foreign_keys]
|
|
241
|
+
expect(fk_recommendations).not_to be_empty
|
|
242
|
+
|
|
243
|
+
user_id_index = fk_recommendations.find { |r| r[:column] == 'user_id' }
|
|
244
|
+
expect(user_id_index).not_to be_nil
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
it 'recommends indexes for date columns' do
|
|
248
|
+
generator.generate('posts')
|
|
249
|
+
recommendations = generator.index_recommendations
|
|
250
|
+
|
|
251
|
+
date_recommendations = recommendations[:date_queries]
|
|
252
|
+
created_at_index = date_recommendations.find { |r| r[:column] == 'created_at' }
|
|
253
|
+
expect(created_at_index).not_to be_nil if date_recommendations.any?
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
it 'recommends full-text indexes for text columns' do
|
|
257
|
+
generator.generate('posts')
|
|
258
|
+
recommendations = generator.index_recommendations
|
|
259
|
+
|
|
260
|
+
fulltext_recommendations = recommendations[:full_text]
|
|
261
|
+
expect(fulltext_recommendations).to be_an(Array)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
describe '#enum_definitions' do
|
|
266
|
+
it 'generates Rails enum definitions' do
|
|
267
|
+
generator.generate('users')
|
|
268
|
+
definitions = generator.enum_definitions
|
|
269
|
+
|
|
270
|
+
expect(definitions).to be_an(Array)
|
|
271
|
+
expect(definitions).not_to be_empty
|
|
272
|
+
|
|
273
|
+
# Check that definitions are in the correct format
|
|
274
|
+
definitions.each do |definition|
|
|
275
|
+
expect(definition).to match(/^enum \w+: \{.+\}$/)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
describe '#polymorphic_has_many_suggestions' do
|
|
281
|
+
it 'suggests has_many associations for polymorphic parents' do
|
|
282
|
+
generator.generate('comments')
|
|
283
|
+
suggestions = generator.polymorphic_has_many_suggestions
|
|
284
|
+
|
|
285
|
+
expect(suggestions).to be_an(Array)
|
|
286
|
+
expect(suggestions).not_to be_empty if generator.has_polymorphic_associations?
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
describe '#model' do
|
|
291
|
+
it 'returns the active record model name' do
|
|
292
|
+
generator.generate('users')
|
|
293
|
+
expect(generator.model).to eq('User')
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
it 'removes trailing s from model names' do
|
|
297
|
+
generator.generate('posts')
|
|
298
|
+
expect(generator.model).to eq('Post')
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
it 'handles underscored table names' do
|
|
302
|
+
generator.generate('post_tags')
|
|
303
|
+
expect(generator.model).to eq('PostTag')
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
describe '#disconnect' do
|
|
308
|
+
it 'closes the database connection' do
|
|
309
|
+
conn_params = {
|
|
310
|
+
host: ENV['POSTGRES_HOST'] || 'localhost',
|
|
311
|
+
port: ENV['POSTGRES_PORT'] || '5432',
|
|
312
|
+
dbname: ENV['POSTGRES_DATABASE'] || 'test_db',
|
|
313
|
+
user: ENV['POSTGRES_USER'] || 'postgres'
|
|
314
|
+
}
|
|
315
|
+
conn_params[:password] = ENV['POSTGRES_PASSWORD'] if ENV['POSTGRES_PASSWORD']
|
|
316
|
+
|
|
317
|
+
test_conn = PG.connect(conn_params)
|
|
318
|
+
|
|
319
|
+
test_gen = described_class.new(test_conn)
|
|
320
|
+
test_gen.disconnect
|
|
321
|
+
|
|
322
|
+
expect(test_conn.finished?).to be true
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require 'pg'
|
|
2
|
+
|
|
3
|
+
# Shared context for PostgreSQL database connection and test data
|
|
4
|
+
RSpec.shared_context 'PostgreSQL connection' do
|
|
5
|
+
before(:context) do
|
|
6
|
+
@pg_connection = begin
|
|
7
|
+
# In Docker environment, use the service name instead of localhost
|
|
8
|
+
host = ENV['POSTGRES_HOST'] || 'localhost'
|
|
9
|
+
port = ENV['POSTGRES_PORT'] || '5432'
|
|
10
|
+
database = ENV['POSTGRES_DATABASE'] || 'test_db'
|
|
11
|
+
user = ENV['POSTGRES_USER'] || 'postgres'
|
|
12
|
+
password = ENV['POSTGRES_PASSWORD']
|
|
13
|
+
|
|
14
|
+
conn_params = {
|
|
15
|
+
host: host,
|
|
16
|
+
port: port,
|
|
17
|
+
dbname: database,
|
|
18
|
+
user: user
|
|
19
|
+
}
|
|
20
|
+
conn_params[:password] = password if password
|
|
21
|
+
|
|
22
|
+
PG.connect(conn_params)
|
|
23
|
+
rescue PG::Error => e
|
|
24
|
+
warn "Database connection failed: #{e.message}"
|
|
25
|
+
warn "Make sure PostgreSQL database is running and accessible"
|
|
26
|
+
warn "Connection string: #{host}:#{port}/#{database}"
|
|
27
|
+
warn "User: #{user}"
|
|
28
|
+
raise e
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Ensure test data exists
|
|
32
|
+
setup_test_data
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
after(:context) do
|
|
36
|
+
# Cleanup test data if needed
|
|
37
|
+
cleanup_test_data
|
|
38
|
+
|
|
39
|
+
# Close the connection
|
|
40
|
+
@pg_connection&.close
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Use a method instead of let for context-level variables
|
|
44
|
+
def connection
|
|
45
|
+
@pg_connection
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def setup_test_data
|
|
51
|
+
# The init-db.sql script should have already created the tables and data
|
|
52
|
+
# This is just a verification step
|
|
53
|
+
begin
|
|
54
|
+
result = @pg_connection.exec("SELECT COUNT(*) FROM users")
|
|
55
|
+
count = result.first['count'].to_i
|
|
56
|
+
result.clear
|
|
57
|
+
|
|
58
|
+
if count == 0
|
|
59
|
+
warn "Warning: Test data not found. Make sure init-db.sql has been run."
|
|
60
|
+
end
|
|
61
|
+
rescue PG::Error => e
|
|
62
|
+
warn "Error checking test data: #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def cleanup_test_data
|
|
67
|
+
# In Docker environment, we don't need to cleanup as containers are disposable
|
|
68
|
+
# In local environment, you might want to clean up test data here
|
|
69
|
+
# For now, we'll leave the data intact for debugging purposes
|
|
70
|
+
end
|
|
71
|
+
end
|
data.tar.gz.sig
CHANGED
|
Binary file
|