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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.evilution.yml +74 -0
  3. data/.github/workflows/ci.yml +1 -1
  4. data/.gitignore +3 -0
  5. data/.rubocop.yml +13 -2
  6. data/CHANGELOG.md +78 -0
  7. data/Gemfile +2 -0
  8. data/Gemfile.lock +96 -78
  9. data/README.md +174 -3
  10. data/Rakefile +2 -0
  11. data/bin/benchmark +42 -7
  12. data/bin/console +1 -0
  13. data/lib/generators/pg_objects/install/install_generator.rb +2 -0
  14. data/lib/pg_objects/config.rb +53 -2
  15. data/lib/pg_objects/db_object.rb +6 -1
  16. data/lib/pg_objects/db_object_factory.rb +11 -3
  17. data/lib/pg_objects/logger.rb +34 -3
  18. data/lib/pg_objects/manager.rb +66 -15
  19. data/lib/pg_objects/parsed_object/aggregate.rb +9 -1
  20. data/lib/pg_objects/parsed_object/base.rb +38 -0
  21. data/lib/pg_objects/parsed_object/base_type.rb +17 -0
  22. data/lib/pg_objects/parsed_object/conversion.rb +9 -1
  23. data/lib/pg_objects/parsed_object/domain.rb +16 -0
  24. data/lib/pg_objects/parsed_object/enum_type.rb +16 -0
  25. data/lib/pg_objects/parsed_object/event_trigger.rb +3 -1
  26. data/lib/pg_objects/parsed_object/extension.rb +10 -0
  27. data/lib/pg_objects/parsed_object/function.rb +9 -1
  28. data/lib/pg_objects/parsed_object/index.rb +17 -0
  29. data/lib/pg_objects/parsed_object/materialized_view.rb +9 -1
  30. data/lib/pg_objects/parsed_object/operator.rb +9 -1
  31. data/lib/pg_objects/parsed_object/operator_class.rb +9 -1
  32. data/lib/pg_objects/parsed_object/policy.rb +10 -0
  33. data/lib/pg_objects/parsed_object/range_type.rb +16 -0
  34. data/lib/pg_objects/parsed_object/rule.rb +10 -0
  35. data/lib/pg_objects/parsed_object/sequence.rb +16 -0
  36. data/lib/pg_objects/parsed_object/table.rb +9 -1
  37. data/lib/pg_objects/parsed_object/text_search_parser.rb +9 -1
  38. data/lib/pg_objects/parsed_object/text_search_template.rb +9 -1
  39. data/lib/pg_objects/parsed_object/trigger.rb +3 -1
  40. data/lib/pg_objects/parsed_object/type.rb +9 -1
  41. data/lib/pg_objects/parsed_object/view.rb +9 -1
  42. data/lib/pg_objects/parsed_object.rb +11 -0
  43. data/lib/pg_objects/parsed_object_factory.rb +52 -90
  44. data/lib/pg_objects/parser.rb +28 -12
  45. data/lib/pg_objects/railtie.rb +2 -0
  46. data/lib/pg_objects/version.rb +3 -1
  47. data/lib/pg_objects/yaml_configurable.rb +25 -7
  48. data/lib/pg_objects.rb +54 -4
  49. data/lib/tasks/pg_objects_tasks.rake +33 -8
  50. data/pg_objects.gemspec +5 -3
  51. metadata +17 -20
  52. 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
- # Specify whether to suppress output to console
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 # whether to suppress output to console, default: false
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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'bundler/gem_tasks'
2
4
  require 'rspec/core/rake_task'
3
5
 
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
- # Count parse errors but continue
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
- # Ignore parse errors for memory analysis
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
- `ps -o rss= -p #{Process.pid}`.to_i * 1024 # Convert KB to bytes
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
- 0
426
+ nil
392
427
  end
393
428
 
394
429
  def format_memory(bytes)
data/bin/console CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  require 'bundler/setup'
4
5
  require 'pg_objects'
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  ##
2
4
  # Creates default directories structure
3
5
  class PgObjects::InstallGenerator < Rails::Generators::Base
@@ -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
- load_from_yaml 'config/pg_objects.yml'
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
@@ -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
- include Import['parser']
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
- db_object = PgObjects::DbObject.new(path, status, parser:)
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
@@ -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
- puts "== #{str} ".ljust(80, '=') unless config.silent
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
@@ -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(config, logger).load_files(:before).create_objects
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
- objects.each { create_object(_1) }
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 ActiveRecord::Base.connection.adapter_name != 'PostgreSQL'
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.name if obj.status == :processing
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.dependencies)
76
+ create_dependencies(obj, stack + [obj.name])
50
77
 
51
78
  logger.write("creating #{obj.name}")
52
- ActiveRecord::Base.connection.execute(obj.sql_query)
79
+ connection.execute(obj.sql_query)
53
80
 
54
81
  obj.status = :done
55
82
  end
56
83
 
57
- def create_dependencies(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
- def find_object(dep_name)
62
- result = objects.select { |obj| [obj.name, obj.full_name, obj.object_name].compact.include? dep_name }
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
- raise PgObjects::AmbiguousDependencyError, dep_name if result.size > 1
65
- raise PgObjects::DependencyNotExistError, dep_name if result.empty?
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[0].string.sval
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