pg_objects 1.4.7 → 1.5.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
- data/.evilution.yml +74 -0
- data/.github/workflows/ci.yml +1 -1
- data/.gitignore +3 -0
- data/.rubocop.yml +13 -2
- data/CHANGELOG.md +78 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +96 -78
- data/README.md +174 -3
- data/Rakefile +2 -0
- data/bin/benchmark +42 -7
- data/bin/console +1 -0
- data/lib/generators/pg_objects/install/install_generator.rb +2 -0
- data/lib/pg_objects/config.rb +53 -2
- data/lib/pg_objects/db_object.rb +6 -1
- data/lib/pg_objects/db_object_factory.rb +11 -3
- data/lib/pg_objects/logger.rb +34 -3
- data/lib/pg_objects/manager.rb +66 -15
- data/lib/pg_objects/parsed_object/aggregate.rb +9 -1
- data/lib/pg_objects/parsed_object/base.rb +38 -0
- data/lib/pg_objects/parsed_object/base_type.rb +17 -0
- data/lib/pg_objects/parsed_object/conversion.rb +9 -1
- data/lib/pg_objects/parsed_object/domain.rb +16 -0
- data/lib/pg_objects/parsed_object/enum_type.rb +16 -0
- data/lib/pg_objects/parsed_object/event_trigger.rb +3 -1
- data/lib/pg_objects/parsed_object/extension.rb +10 -0
- data/lib/pg_objects/parsed_object/function.rb +9 -1
- data/lib/pg_objects/parsed_object/index.rb +17 -0
- data/lib/pg_objects/parsed_object/materialized_view.rb +9 -1
- data/lib/pg_objects/parsed_object/operator.rb +9 -1
- data/lib/pg_objects/parsed_object/operator_class.rb +9 -1
- data/lib/pg_objects/parsed_object/policy.rb +10 -0
- data/lib/pg_objects/parsed_object/range_type.rb +16 -0
- data/lib/pg_objects/parsed_object/rule.rb +10 -0
- data/lib/pg_objects/parsed_object/sequence.rb +16 -0
- data/lib/pg_objects/parsed_object/table.rb +9 -1
- data/lib/pg_objects/parsed_object/text_search_parser.rb +9 -1
- data/lib/pg_objects/parsed_object/text_search_template.rb +9 -1
- data/lib/pg_objects/parsed_object/trigger.rb +3 -1
- data/lib/pg_objects/parsed_object/type.rb +9 -1
- data/lib/pg_objects/parsed_object/view.rb +9 -1
- data/lib/pg_objects/parsed_object.rb +11 -0
- data/lib/pg_objects/parsed_object_factory.rb +52 -90
- data/lib/pg_objects/parser.rb +28 -12
- data/lib/pg_objects/railtie.rb +2 -0
- data/lib/pg_objects/version.rb +3 -1
- data/lib/pg_objects/yaml_configurable.rb +25 -7
- data/lib/pg_objects.rb +54 -4
- data/lib/tasks/pg_objects_tasks.rake +33 -8
- data/pg_objects.gemspec +5 -3
- metadata +17 -20
- data/.github/copilot-instructions.md +0 -131
data/README.md
CHANGED
|
@@ -32,6 +32,18 @@ Run the installation procedure to initialize directories structure and configura
|
|
|
32
32
|
bundle exec rails generate pg_objects:install
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
## Supported object types
|
|
36
|
+
|
|
37
|
+
The following `CREATE` statements are recognized as manageable objects:
|
|
38
|
+
|
|
39
|
+
`AGGREGATE`, `CONVERSION`, `DOMAIN`, `EVENT TRIGGER`, `EXTENSION`, `FUNCTION`,
|
|
40
|
+
`INDEX`, `MATERIALIZED VIEW`, `OPERATOR`, `OPERATOR CLASS`, `POLICY`, `RULE`,
|
|
41
|
+
`SEQUENCE`, `TABLE`, `TEXT SEARCH PARSER`, `TEXT SEARCH TEMPLATE`, `TRIGGER`,
|
|
42
|
+
`TYPE` (composite, enum, range, base, and shell forms), `VIEW`.
|
|
43
|
+
|
|
44
|
+
Files containing any other statement raise
|
|
45
|
+
`PgObjects::UnknownObjectTypeError` during loading.
|
|
46
|
+
|
|
35
47
|
## Usage
|
|
36
48
|
|
|
37
49
|
Store DB objects as CREATE (or CREATE OR UPDATE) queries in files within a directory structure (default: *db/objects*).
|
|
@@ -46,6 +58,21 @@ CREATE FUNCTION my_func()
|
|
|
46
58
|
|
|
47
59
|
The string after the directive should be the name of the file that the dependency refers to, without the file extension.
|
|
48
60
|
|
|
61
|
+
### Directive syntax
|
|
62
|
+
|
|
63
|
+
- The directive must start the line — no leading whitespace.
|
|
64
|
+
- Both `--!` (SQL comment) and `#!` prefixes are supported.
|
|
65
|
+
- List several dependencies on one line, separated by commas and/or whitespace,
|
|
66
|
+
or use a separate directive line for each:
|
|
67
|
+
|
|
68
|
+
```sql
|
|
69
|
+
--!depends_on func_a, func_b, func_c
|
|
70
|
+
--!depends_on func_d
|
|
71
|
+
#!depends_on func_e
|
|
72
|
+
CREATE FUNCTION my_func()
|
|
73
|
+
...
|
|
74
|
+
```
|
|
75
|
+
|
|
49
76
|
## Configuration
|
|
50
77
|
|
|
51
78
|
You have the option to configure the gem using either a YAML file or a Ruby initializer. The priority order for configuration is as follows:
|
|
@@ -55,7 +82,13 @@ You have the option to configure the gem using either a YAML file or a Ruby init
|
|
|
55
82
|
|
|
56
83
|
### YAML
|
|
57
84
|
|
|
58
|
-
Create `pg_objects.yml` in the application *config* directory
|
|
85
|
+
Create `pg_objects.yml` in the application *config* directory.
|
|
86
|
+
|
|
87
|
+
The file is loaded on first configuration access and resolved against
|
|
88
|
+
`Rails.root` in Rails applications, so it is found even when the gem is
|
|
89
|
+
required before the process changes into the app root (e.g. under the Spring
|
|
90
|
+
preloader). Outside Rails the path is relative to the current working
|
|
91
|
+
directory.
|
|
59
92
|
|
|
60
93
|
```yaml
|
|
61
94
|
# pg_objects.yml
|
|
@@ -70,8 +103,18 @@ extensions:
|
|
|
70
103
|
- sql
|
|
71
104
|
- txt
|
|
72
105
|
|
|
73
|
-
#
|
|
106
|
+
# Suppress non-error output to console (error messages are always printed)
|
|
74
107
|
silent: false
|
|
108
|
+
|
|
109
|
+
# Whether to wrap each object-creation run in a database transaction so a
|
|
110
|
+
# failure rolls back everything created in that run (default: true).
|
|
111
|
+
# Note: some PostgreSQL statements cannot run inside a transaction
|
|
112
|
+
# (e.g. CREATE INDEX CONCURRENTLY, VACUUM) — set to false if your
|
|
113
|
+
# object files contain them.
|
|
114
|
+
transactional: true
|
|
115
|
+
|
|
116
|
+
# Whether to install the Rake hooks that auto-create objects (default: true)
|
|
117
|
+
auto_hook_migrations: true
|
|
75
118
|
```
|
|
76
119
|
|
|
77
120
|
### Initializer
|
|
@@ -83,7 +126,8 @@ PgObjects.configure do |config|
|
|
|
83
126
|
config.before_path = 'path/to/objects/before' # default: 'db/objects/before'
|
|
84
127
|
config.after_path = 'path/to/objects/after' # default: 'db/objects/after'
|
|
85
128
|
config.extensions = ['sql', 'txt'] # default: 'sql'
|
|
86
|
-
config.silent = true #
|
|
129
|
+
config.silent = true # suppress non-error output to console (errors are always printed), default: false
|
|
130
|
+
config.transactional = false # opt out of the wrapping transaction, default: true
|
|
87
131
|
end
|
|
88
132
|
```
|
|
89
133
|
|
|
@@ -91,6 +135,118 @@ Otherwise, the default values will be used.
|
|
|
91
135
|
|
|
92
136
|
Please make sure to verify that the specified directories actually exist.
|
|
93
137
|
|
|
138
|
+
> [!NOTE]
|
|
139
|
+
> Object creation runs inside a single database transaction by default, so a
|
|
140
|
+
> failure mid-run rolls back every object created in that run. PostgreSQL
|
|
141
|
+
> rejects some statements inside a transaction block — for example
|
|
142
|
+
> `CREATE INDEX CONCURRENTLY`, `VACUUM`, or `ALTER TYPE ... ADD VALUE` on
|
|
143
|
+
> PostgreSQL versions before 12. If your object files contain such statements,
|
|
144
|
+
> set `config.transactional = false`.
|
|
145
|
+
|
|
146
|
+
### Rake tasks that trigger object creation
|
|
147
|
+
|
|
148
|
+
Object creation is hooked into Rake tasks. By default:
|
|
149
|
+
|
|
150
|
+
| Task | `before` objects | `after` objects |
|
|
151
|
+
| --- | :---: | :---: |
|
|
152
|
+
| `db:migrate` | ✓ | ✓ |
|
|
153
|
+
| `db:schema:load` | | ✓ |
|
|
154
|
+
| `db:migrate:redo` | ✓ | ✓ |
|
|
155
|
+
|
|
156
|
+
`db:rollback` is **not** hooked — rolling a migration back should not recreate
|
|
157
|
+
objects.
|
|
158
|
+
|
|
159
|
+
You can also invoke the underlying tasks directly: `db:create_objects:before`
|
|
160
|
+
and `db:create_objects:after`.
|
|
161
|
+
|
|
162
|
+
### Multiple databases
|
|
163
|
+
|
|
164
|
+
By default objects are created over the global connection
|
|
165
|
+
(`ActiveRecord::Base.connection`). In a Rails multi-database setup, target a
|
|
166
|
+
specific database by passing its connection to the manager:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
PgObjects::Manager.new(connection: AnimalsRecord.connection).load_files(:before).create_objects
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
For the rake tasks, set `PG_OBJECTS_CONNECTION_CLASS` to the name of the
|
|
173
|
+
Active Record class whose connection should be used:
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
PG_OBJECTS_CONNECTION_CLASS=AnimalsRecord bin/rails db:create_objects:before
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
To disable all hooks entirely, set `auto_hook_migrations` to `false` in an
|
|
180
|
+
initializer; then create objects only by invoking those tasks manually:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
PgObjects.configure do |config|
|
|
184
|
+
config.auto_hook_migrations = false # default: true
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Re-running and idempotency
|
|
189
|
+
|
|
190
|
+
The hooked tasks re-execute **every** object file on each run — the gem keeps
|
|
191
|
+
no state about what was already created. Files must therefore contain
|
|
192
|
+
re-runnable (idempotent) SQL, or the second `db:migrate` fails with errors
|
|
193
|
+
like `relation "..." already exists`.
|
|
194
|
+
|
|
195
|
+
What PostgreSQL offers per object type:
|
|
196
|
+
|
|
197
|
+
| Object type | Idempotent form |
|
|
198
|
+
| --- | --- |
|
|
199
|
+
| `FUNCTION`, `VIEW` | `CREATE OR REPLACE` |
|
|
200
|
+
| `RULE` | `CREATE OR REPLACE` |
|
|
201
|
+
| `AGGREGATE` | `CREATE OR REPLACE` (PostgreSQL 12+) |
|
|
202
|
+
| `TRIGGER` | `CREATE OR REPLACE` (PostgreSQL 14+), otherwise drop-and-recreate |
|
|
203
|
+
| `TABLE`, `INDEX`, `SEQUENCE`, `MATERIALIZED VIEW`, `EXTENSION` | `IF NOT EXISTS` |
|
|
204
|
+
| `TYPE`, `DOMAIN`, `POLICY`, `CONVERSION`, `OPERATOR`, `OPERATOR CLASS`, `EVENT TRIGGER`, `TEXT SEARCH PARSER/TEMPLATE` | none — use a guard (below) |
|
|
205
|
+
|
|
206
|
+
For types without an idempotent form, either drop first:
|
|
207
|
+
|
|
208
|
+
```sql
|
|
209
|
+
DROP POLICY IF EXISTS user_policy ON users;
|
|
210
|
+
CREATE POLICY user_policy ON users USING (user_id = current_user_id());
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
or swallow the duplicate error in a `DO` block:
|
|
214
|
+
|
|
215
|
+
```sql
|
|
216
|
+
DO $$ BEGIN
|
|
217
|
+
CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy');
|
|
218
|
+
EXCEPTION WHEN duplicate_object THEN NULL;
|
|
219
|
+
END $$;
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
> [!NOTE]
|
|
223
|
+
> Object files are classified by parsing their **first** statement. When that
|
|
224
|
+
> statement is a `DROP` or a `DO` block (as in the guards above), the SQL
|
|
225
|
+
> object name cannot be extracted. The file still executes normally, but it
|
|
226
|
+
> can only be referenced by its **file identifiers** — the extensionless file
|
|
227
|
+
> name or the file path — while `--!depends_on` directives referring to the
|
|
228
|
+
> SQL object name (or its schema-qualified form) will not match it.
|
|
229
|
+
> The plain `IF NOT EXISTS` / `OR REPLACE` forms keep full name resolution.
|
|
230
|
+
|
|
231
|
+
Also note that object creation runs inside a single transaction by default
|
|
232
|
+
(see above), so one failing statement rolls back the entire run — a
|
|
233
|
+
half-idempotent set of files either all applies or not at all.
|
|
234
|
+
|
|
235
|
+
Override `hook_tasks` to customize which tasks/stages are hooked (e.g. an empty
|
|
236
|
+
hash also disables all hooks). Configure it in a Rails initializer: the hooks are installed when the
|
|
237
|
+
gem's rake tasks load, which happens after initializers run, so an initializer
|
|
238
|
+
value is always picked up. Changing `hook_tasks` later (after task loading) has
|
|
239
|
+
no effect on which hooks are installed.
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
PgObjects.configure do |config|
|
|
243
|
+
config.hook_tasks = {
|
|
244
|
+
'db:migrate' => %i[before after],
|
|
245
|
+
'db:schema:load' => %i[after]
|
|
246
|
+
}
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
94
250
|
## Development
|
|
95
251
|
|
|
96
252
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
@@ -145,3 +301,18 @@ Full Workflow Performance:
|
|
|
145
301
|
## Contributing
|
|
146
302
|
|
|
147
303
|
Bug reports and pull requests are welcome on GitHub at https://github.com/marinazzio/pg_objects.
|
|
304
|
+
|
|
305
|
+
Every pull request that changes gem behavior must add an entry to the
|
|
306
|
+
`[Unreleased]` section of [CHANGELOG.md](CHANGELOG.md) (following the
|
|
307
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format). Pure
|
|
308
|
+
refactorings, spec-only, and dependency-bump PRs may skip this.
|
|
309
|
+
|
|
310
|
+
## Releasing
|
|
311
|
+
|
|
312
|
+
1. Move the `[Unreleased]` entries in `CHANGELOG.md` under a new version
|
|
313
|
+
heading with the release date, and update the comparison links at the
|
|
314
|
+
bottom of the file.
|
|
315
|
+
2. Bump `PgObjects::VERSION` in `lib/pg_objects/version.rb` accordingly
|
|
316
|
+
(semantic versioning).
|
|
317
|
+
3. Tag the commit `vX.Y.Z` and publish a GitHub release — the publish
|
|
318
|
+
workflow builds and pushes the gem to RubyGems.
|
data/Rakefile
CHANGED
data/bin/benchmark
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
require 'bundler/setup'
|
|
5
|
+
require 'active_support/core_ext/string/inflections' # String#classify / #constantize used by the parser
|
|
4
6
|
require 'pg_objects'
|
|
5
7
|
require 'benchmark'
|
|
6
8
|
require 'fileutils'
|
|
@@ -242,6 +244,7 @@ class PgObjectsBenchmark
|
|
|
242
244
|
parser = PgObjects::Parser.new
|
|
243
245
|
successful_parses = 0
|
|
244
246
|
parse_errors = 0
|
|
247
|
+
sample_error = nil
|
|
245
248
|
|
|
246
249
|
result = Benchmark.measure do
|
|
247
250
|
files.each do |file|
|
|
@@ -249,8 +252,9 @@ class PgObjectsBenchmark
|
|
|
249
252
|
begin
|
|
250
253
|
parser.load(content).fetch_object_name
|
|
251
254
|
successful_parses += 1
|
|
252
|
-
rescue StandardError
|
|
255
|
+
rescue StandardError => e
|
|
253
256
|
parse_errors += 1
|
|
257
|
+
sample_error ||= "#{e.class}: #{e.message}"
|
|
254
258
|
end
|
|
255
259
|
end
|
|
256
260
|
end
|
|
@@ -260,12 +264,14 @@ class PgObjectsBenchmark
|
|
|
260
264
|
files_count: files.size,
|
|
261
265
|
successful_parses: successful_parses,
|
|
262
266
|
parse_errors: parse_errors,
|
|
267
|
+
sample_error: sample_error,
|
|
263
268
|
throughput: files.size / result.real
|
|
264
269
|
}
|
|
265
270
|
|
|
266
271
|
puts "Parsed #{files.size} files in #{result.real.round(4)}s"
|
|
267
272
|
puts "Successful parses: #{successful_parses}"
|
|
268
273
|
puts "Parse errors: #{parse_errors}"
|
|
274
|
+
puts " Sample error: #{sample_error}" if sample_error
|
|
269
275
|
puts "Throughput: #{(files.size / result.real).round(2)} files/second"
|
|
270
276
|
puts "Average time per file: #{(result.real / files.size * 1000).round(2)}ms"
|
|
271
277
|
puts
|
|
@@ -308,6 +314,8 @@ class PgObjectsBenchmark
|
|
|
308
314
|
# Test the parsing workflow without database operations
|
|
309
315
|
files = Dir[File.join(@temp_dir, '**', '*.sql')]
|
|
310
316
|
db_objects = []
|
|
317
|
+
workflow_errors = 0
|
|
318
|
+
sample_error = nil
|
|
311
319
|
|
|
312
320
|
result = Benchmark.measure do
|
|
313
321
|
files.each do |file_path|
|
|
@@ -327,18 +335,23 @@ class PgObjectsBenchmark
|
|
|
327
335
|
dependencies: dependencies,
|
|
328
336
|
content: content
|
|
329
337
|
}
|
|
330
|
-
rescue StandardError
|
|
331
|
-
|
|
338
|
+
rescue StandardError => e
|
|
339
|
+
workflow_errors += 1
|
|
340
|
+
sample_error ||= "#{e.class}: #{e.message}"
|
|
332
341
|
end
|
|
333
342
|
end
|
|
334
343
|
|
|
335
344
|
@results[:full_workflow] = {
|
|
336
345
|
time: result.real,
|
|
337
346
|
objects_count: db_objects.size,
|
|
347
|
+
errors: workflow_errors,
|
|
348
|
+
sample_error: sample_error,
|
|
338
349
|
throughput: db_objects.size / result.real
|
|
339
350
|
}
|
|
340
351
|
|
|
341
352
|
puts "Processed #{db_objects.size} objects in #{result.real.round(4)}s"
|
|
353
|
+
puts "Errors: #{workflow_errors}"
|
|
354
|
+
puts " Sample error: #{sample_error}" if sample_error
|
|
342
355
|
puts "Throughput: #{(db_objects.size / result.real).round(2)} objects/second"
|
|
343
356
|
puts "Average time per object: #{(result.real / db_objects.size * 1000).round(2)}ms"
|
|
344
357
|
puts
|
|
@@ -354,6 +367,8 @@ class PgObjectsBenchmark
|
|
|
354
367
|
files = Dir[File.join(@temp_dir, '**', '*.sql')]
|
|
355
368
|
parser = PgObjects::Parser.new
|
|
356
369
|
parsed_objects = []
|
|
370
|
+
memory_errors = 0
|
|
371
|
+
sample_error = nil
|
|
357
372
|
|
|
358
373
|
files.each do |file|
|
|
359
374
|
content = File.read(file)
|
|
@@ -361,11 +376,15 @@ class PgObjectsBenchmark
|
|
|
361
376
|
object_name = parser.load(content).fetch_object_name
|
|
362
377
|
dependencies = parser.fetch_directives[:depends_on]
|
|
363
378
|
parsed_objects << { file: file, name: object_name, deps: dependencies }
|
|
364
|
-
rescue StandardError
|
|
365
|
-
|
|
379
|
+
rescue StandardError => e
|
|
380
|
+
memory_errors += 1
|
|
381
|
+
sample_error ||= "#{e.class}: #{e.message}"
|
|
366
382
|
end
|
|
367
383
|
end
|
|
368
384
|
|
|
385
|
+
puts "Parse errors during memory analysis: #{memory_errors}" if memory_errors.positive?
|
|
386
|
+
puts " Sample error: #{sample_error}" if sample_error
|
|
387
|
+
|
|
369
388
|
end_memory = memory_usage
|
|
370
389
|
memory_diff = end_memory - start_memory
|
|
371
390
|
|
|
@@ -386,9 +405,25 @@ class PgObjectsBenchmark
|
|
|
386
405
|
end
|
|
387
406
|
|
|
388
407
|
def memory_usage
|
|
389
|
-
|
|
408
|
+
rss_from_proc_status || rss_from_ps || 0
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Linux: parse VmRSS from /proc/self/status (portable, no shell, locale-safe)
|
|
412
|
+
def rss_from_proc_status
|
|
413
|
+
return nil unless File.readable?('/proc/self/status')
|
|
414
|
+
|
|
415
|
+
kb = File.read('/proc/self/status')[/^VmRSS:\s+(\d+)\s+kB/, 1]
|
|
416
|
+
kb && (kb.to_i * 1024) # Convert KB to bytes
|
|
417
|
+
rescue StandardError
|
|
418
|
+
nil
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Portable fallback (e.g. macOS): RSS in KB via ps
|
|
422
|
+
def rss_from_ps
|
|
423
|
+
kb = `ps -o rss= -p #{Process.pid}`.strip
|
|
424
|
+
kb.empty? ? nil : kb.to_i * 1024
|
|
390
425
|
rescue StandardError
|
|
391
|
-
|
|
426
|
+
nil
|
|
392
427
|
end
|
|
393
428
|
|
|
394
429
|
def format_memory(bytes)
|
data/bin/console
CHANGED
data/lib/pg_objects/config.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require_relative 'yaml_configurable'
|
|
2
4
|
|
|
3
5
|
module PgObjects
|
|
@@ -10,7 +12,7 @@ module PgObjects
|
|
|
10
12
|
# # or full
|
|
11
13
|
# config.after_path = '/var/tmp/alternate/after'
|
|
12
14
|
# config.extensions = ['sql', 'txt']
|
|
13
|
-
# # suppress output to console
|
|
15
|
+
# # suppress non-error output to console
|
|
14
16
|
# config.silent = true
|
|
15
17
|
# end
|
|
16
18
|
class << self
|
|
@@ -32,6 +34,55 @@ module PgObjects
|
|
|
32
34
|
setting :extensions, default: ['sql']
|
|
33
35
|
setting :silent, default: false
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
# Wraps each create_objects run in a database transaction so a failure
|
|
38
|
+
# mid-run rolls back every object created in that run. Set to false to
|
|
39
|
+
# execute statements without a wrapping transaction — required when object
|
|
40
|
+
# files contain statements PostgreSQL rejects inside a transaction block
|
|
41
|
+
# (e.g. CREATE INDEX CONCURRENTLY, VACUUM).
|
|
42
|
+
setting :transactional, default: true
|
|
43
|
+
|
|
44
|
+
# Master switch for the Rake hooks. When false, no hooks are installed and
|
|
45
|
+
# objects are created only by invoking db:create_objects:before / :after
|
|
46
|
+
# manually. Default true for backward compatibility.
|
|
47
|
+
setting :auto_hook_migrations, default: true
|
|
48
|
+
|
|
49
|
+
# Rake tasks that trigger object creation, mapped to the stages they run.
|
|
50
|
+
# +:before+ creates objects from the "before" folder ahead of the task,
|
|
51
|
+
# +:after+ creates objects from the "after" folder once the task finishes.
|
|
52
|
+
# Override (or empty) this to opt out. db:rollback is intentionally absent
|
|
53
|
+
# (a rollback should not recreate objects).
|
|
54
|
+
setting :hook_tasks, default: {
|
|
55
|
+
'db:migrate' => %i[before after],
|
|
56
|
+
'db:schema:load' => %i[after],
|
|
57
|
+
'db:migrate:redo' => %i[before after]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
DEFAULT_YAML_PATH = 'config/pg_objects.yml'
|
|
61
|
+
|
|
62
|
+
class << self
|
|
63
|
+
# YAML loading is deferred to the first config access so the path is
|
|
64
|
+
# resolved against Rails.root (when available) instead of the
|
|
65
|
+
# require-time working directory — preloaders like Spring may require
|
|
66
|
+
# the gem before the process chdirs into the app root.
|
|
67
|
+
def config
|
|
68
|
+
ensure_yaml_loaded
|
|
69
|
+
super
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def ensure_yaml_loaded
|
|
75
|
+
return if @yaml_loaded
|
|
76
|
+
|
|
77
|
+
@yaml_loaded = true
|
|
78
|
+
load_from_yaml(yaml_config_path)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def yaml_config_path
|
|
82
|
+
return DEFAULT_YAML_PATH unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
83
|
+
|
|
84
|
+
Rails.root.join(DEFAULT_YAML_PATH).to_s
|
|
85
|
+
end
|
|
86
|
+
end
|
|
36
87
|
end
|
|
37
88
|
end
|
data/lib/pg_objects/db_object.rb
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
##
|
|
2
4
|
# Represents DB object as it is described in file
|
|
3
5
|
#
|
|
4
6
|
# [name] name of file without extension
|
|
5
7
|
# [full_name] full pathname of file
|
|
6
8
|
# [object_name] name of function, trigger etc. if it was successfully parsed, otherwise - nil
|
|
9
|
+
# [qualified_object_name] schema-qualified object name (+schema.name+) when a schema is
|
|
10
|
+
# present, otherwise same as object_name; nil if parsing failed
|
|
7
11
|
class PgObjects::DbObject
|
|
8
12
|
include Memery
|
|
9
13
|
|
|
10
14
|
include Import['parser']
|
|
11
15
|
|
|
12
16
|
attr_accessor :status
|
|
13
|
-
attr_reader :full_name, :object_name
|
|
17
|
+
attr_reader :full_name, :object_name, :qualified_object_name
|
|
14
18
|
|
|
15
19
|
def initialize(path, status = :new, parser:)
|
|
16
20
|
@full_name = path
|
|
@@ -21,6 +25,7 @@ class PgObjects::DbObject
|
|
|
21
25
|
def create
|
|
22
26
|
parser.load(sql_query)
|
|
23
27
|
@object_name = parser.fetch_object_name
|
|
28
|
+
@qualified_object_name = parser.fetch_qualified_object_name
|
|
24
29
|
@status = :pending
|
|
25
30
|
|
|
26
31
|
self
|
|
@@ -3,11 +3,19 @@
|
|
|
3
3
|
##
|
|
4
4
|
# Factory for DbObject
|
|
5
5
|
#
|
|
6
|
+
# Each DbObject gets its own Parser instance so the parser's mutable `@source`
|
|
7
|
+
# is never shared between objects. The parser class is
|
|
8
|
+
# injectable so callers/tests can substitute an alternate parser.
|
|
6
9
|
class PgObjects::DbObjectFactory
|
|
7
|
-
|
|
10
|
+
def initialize(parser_class: PgObjects::Parser)
|
|
11
|
+
@parser_class = parser_class
|
|
12
|
+
end
|
|
8
13
|
|
|
9
14
|
def create_instance(path, status: :new)
|
|
10
|
-
|
|
11
|
-
db_object.create
|
|
15
|
+
PgObjects::DbObject.new(path, status, parser: parser_class.new).create
|
|
12
16
|
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
attr_reader :parser_class
|
|
13
21
|
end
|
data/lib/pg_objects/logger.rb
CHANGED
|
@@ -1,10 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
##
|
|
2
|
-
# Console output
|
|
3
|
-
#
|
|
4
|
+
# Console output with severity levels. Writes to the injected +stream+
|
|
5
|
+
# (default +$stdout+). With +config.silent+ enabled, +info+/+warn+ messages
|
|
6
|
+
# are suppressed; +error+ messages are always written.
|
|
4
7
|
class PgObjects::Logger
|
|
5
8
|
include Import['config']
|
|
6
9
|
|
|
10
|
+
def initialize(stream: $stdout, **deps)
|
|
11
|
+
super(**deps)
|
|
12
|
+
@stream = stream
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Backward-compatible entry point; behaves like +info+.
|
|
7
16
|
def write(str)
|
|
8
|
-
|
|
17
|
+
log(str)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def info(str)
|
|
21
|
+
log(str)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def warn(str)
|
|
25
|
+
log("[WARN] #{str}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def error(str)
|
|
29
|
+
log("[ERROR] #{str}", force: true)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
attr_reader :stream
|
|
35
|
+
|
|
36
|
+
def log(str, force: false)
|
|
37
|
+
return if config.silent && !force
|
|
38
|
+
|
|
39
|
+
stream.puts "== #{str} ".ljust(80, '=')
|
|
9
40
|
end
|
|
10
41
|
end
|
data/lib/pg_objects/manager.rb
CHANGED
|
@@ -1,23 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
##
|
|
2
4
|
# Manages process to create objects
|
|
3
5
|
#
|
|
4
|
-
# Usage:
|
|
6
|
+
# Usage (dependencies are auto-injected, keyword overrides optional):
|
|
5
7
|
#
|
|
6
|
-
# Manager.new
|
|
8
|
+
# Manager.new.load_files(:before).create_objects
|
|
7
9
|
#
|
|
8
10
|
# or
|
|
9
11
|
#
|
|
10
|
-
# Manager.new(config, logger).load_files(:after).create_objects
|
|
12
|
+
# Manager.new(config: custom_config, logger: custom_logger).load_files(:after).create_objects
|
|
13
|
+
#
|
|
14
|
+
# Pass +connection:+ to run against a specific database connection instead of
|
|
15
|
+
# the global one (Rails 6+ multi-DB):
|
|
16
|
+
#
|
|
17
|
+
# Manager.new(connection: AnimalsRecord.connection).load_files(:before).create_objects
|
|
11
18
|
class PgObjects::Manager
|
|
12
19
|
include Import['db_object_factory', 'config', 'logger']
|
|
13
20
|
|
|
21
|
+
def initialize(connection: nil, **deps)
|
|
22
|
+
super(**deps)
|
|
23
|
+
@connection = connection
|
|
24
|
+
end
|
|
25
|
+
|
|
14
26
|
##
|
|
15
27
|
# event: +:before+ or +:after+
|
|
16
28
|
#
|
|
17
29
|
# used to reference configuration settings +before_path+ and +after_path+
|
|
30
|
+
#
|
|
31
|
+
# Resets the object list before loading, so each call reflects only the
|
|
32
|
+
# files for the given event.
|
|
18
33
|
def load_files(event)
|
|
19
34
|
validate_workability
|
|
20
35
|
|
|
36
|
+
objects.clear
|
|
21
37
|
dir = config.send "#{event}_path"
|
|
22
38
|
Dir[File.join(dir, '**', "*.{#{config.extensions.join(',')}}")].each do |path|
|
|
23
39
|
objects << db_object_factory.create_instance(path)
|
|
@@ -27,7 +43,8 @@ class PgObjects::Manager
|
|
|
27
43
|
end
|
|
28
44
|
|
|
29
45
|
def create_objects
|
|
30
|
-
|
|
46
|
+
build_objects_index
|
|
47
|
+
within_transaction { objects.each { |obj| create_object(obj) } }
|
|
31
48
|
end
|
|
32
49
|
|
|
33
50
|
def objects
|
|
@@ -36,34 +53,68 @@ class PgObjects::Manager
|
|
|
36
53
|
|
|
37
54
|
private
|
|
38
55
|
|
|
56
|
+
def connection
|
|
57
|
+
@connection || ActiveRecord::Base.connection
|
|
58
|
+
end
|
|
59
|
+
|
|
39
60
|
def validate_workability
|
|
40
|
-
raise PgObjects::UnsupportedAdapterError if
|
|
61
|
+
raise PgObjects::UnsupportedAdapterError if connection.adapter_name != 'PostgreSQL'
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def within_transaction(&)
|
|
65
|
+
return yield unless config.transactional
|
|
66
|
+
|
|
67
|
+
connection.transaction(&)
|
|
41
68
|
end
|
|
42
69
|
|
|
43
|
-
def create_object(obj)
|
|
70
|
+
def create_object(obj, stack = [])
|
|
44
71
|
return if obj.status == :done
|
|
45
|
-
raise PgObjects::CyclicDependencyError, obj
|
|
72
|
+
raise PgObjects::CyclicDependencyError, cycle_path(obj, stack) if obj.status == :processing
|
|
46
73
|
|
|
47
74
|
obj.status = :processing
|
|
48
75
|
|
|
49
|
-
create_dependencies(obj.
|
|
76
|
+
create_dependencies(obj, stack + [obj.name])
|
|
50
77
|
|
|
51
78
|
logger.write("creating #{obj.name}")
|
|
52
|
-
|
|
79
|
+
connection.execute(obj.sql_query)
|
|
53
80
|
|
|
54
81
|
obj.status = :done
|
|
55
82
|
end
|
|
56
83
|
|
|
57
|
-
def create_dependencies(
|
|
58
|
-
dependencies.each { |dep_name| create_object(find_object(dep_name)) }
|
|
84
|
+
def create_dependencies(obj, stack)
|
|
85
|
+
obj.dependencies.each { |dep_name| create_object(find_object(dep_name, obj), stack) }
|
|
59
86
|
end
|
|
60
87
|
|
|
61
|
-
|
|
62
|
-
|
|
88
|
+
# Resolution chain that closed the cycle: from the first occurrence of the
|
|
89
|
+
# revisited object down to the point of revisit, closed with the object
|
|
90
|
+
# itself (e.g. ["a", "b", "a"]).
|
|
91
|
+
def cycle_path(obj, stack)
|
|
92
|
+
first_seen = stack.index(obj.name)
|
|
93
|
+
(first_seen ? stack[first_seen..] : stack) + [obj.name]
|
|
94
|
+
end
|
|
63
95
|
|
|
64
|
-
|
|
65
|
-
|
|
96
|
+
def build_objects_index
|
|
97
|
+
@objects_index = objects.each_with_object({}) do |obj, index|
|
|
98
|
+
[obj.name, obj.full_name, obj.object_name, obj.qualified_object_name].compact.uniq.each do |key|
|
|
99
|
+
(index[key] ||= []) << obj
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def find_object(dep_name, referrer = nil)
|
|
105
|
+
result = @objects_index[dep_name] || []
|
|
106
|
+
|
|
107
|
+
raise ambiguous_error(dep_name, result, referrer) if result.size > 1
|
|
108
|
+
raise PgObjects::DependencyNotExistError.new(dep_name, referrer: referrer&.full_name) if result.empty?
|
|
66
109
|
|
|
67
110
|
result[0]
|
|
68
111
|
end
|
|
112
|
+
|
|
113
|
+
def ambiguous_error(dep_name, result, referrer)
|
|
114
|
+
PgObjects::AmbiguousDependencyError.new(
|
|
115
|
+
dep_name,
|
|
116
|
+
candidates: result.map(&:full_name),
|
|
117
|
+
referrer: referrer&.full_name
|
|
118
|
+
)
|
|
119
|
+
end
|
|
69
120
|
end
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
#
|
|
2
4
|
# AGGREGATE object representation
|
|
3
5
|
#
|
|
4
6
|
class PgObjects::ParsedObject::Aggregate < PgObjects::ParsedObject::Base
|
|
5
7
|
def name
|
|
6
|
-
stmt.define_stmt.defnames
|
|
8
|
+
extract_name { stmt.define_stmt.defnames.last.string.sval }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def schema
|
|
14
|
+
extract_name { qualifier(stmt.define_stmt.defnames) }
|
|
7
15
|
end
|
|
8
16
|
end
|