scenic-cascade 0.1.2 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d210342197b949abc4156e8fcdc63ad74b04f1fde40fb207e35dc15b13e1efd
4
- data.tar.gz: 60c19c6113bc29ebc57c068ef16d744661f33f9bdd06a763807b807e41d3f66f
3
+ metadata.gz: 29ecc270cd637c5654557c3d349c43a59483476d7a0758a3988136d4c3fa4a28
4
+ data.tar.gz: b53b4f0a78d929753627aabcab2dbf75c6e6aad79fe5ee6a77b5a1b45b9b9af0
5
5
  SHA512:
6
- metadata.gz: 9040765c0fdadb9778139f634883a09466ddf80d8b63bf28c8b358150e0d4e6eb45556bb3d926c9e458c7e3fd8bf9deb7b8863d6a80694163f6b73643654ee09
7
- data.tar.gz: 05ea8516e444b9c07cba0a8813af3985295897ce11c90790ca407eb5aa81d5d250e937d334269f5b87c797ee932a2ae7e1e09cff92d82c9a7041f89c35cbd06e
6
+ metadata.gz: 642f9d3a40bf02b8da393a10e645c7609e5562652654c016ad27a6806b86d8bbf4f98ffd4e1554ef4fca5a60dab878badf298bbc0200b38ae1aa16c0baccddf3
7
+ data.tar.gz: b95a92d620f673192b07e82de00151e4e251e1ecaeb13c24df4c6fa85670a71093b04a2d0d44dde9b86bf7e066dc112689292b808eabe27e3549dddea849c134
data/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.3] - 2026-11-01
11
+
12
+ ### Fixed
13
+
14
+ - Recreate dependent views when generating a migration with a Rails table name prefix.
15
+ - Discover and recreate dependent views across PostgreSQL schemas without mixing identically named views or their SQL definitions.
16
+ - Remove generated view definitions and partial migrations when migration generation fails, so retries use the correct version.
17
+ - Apply `--replace` and supported `--side-by-side` options when generating view update migrations.
18
+ - Avoid leaving an orphan SQL definition when `--skip` skips a conflicting migration.
19
+
10
20
  ## [0.1.2] - 2026-11-01
11
21
 
12
22
  ### Fixed
@@ -27,7 +37,8 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
27
37
 
28
38
  - Initial release of the cascading Scenic view migration generator.
29
39
 
30
- [Unreleased]: https://github.com/akiomik/scenic-cascade/compare/v0.1.2...HEAD
40
+ [Unreleased]: https://github.com/akiomik/scenic-cascade/compare/v0.1.3...HEAD
41
+ [0.1.3]: https://github.com/akiomik/scenic-cascade/compare/v0.1.2...v0.1.3
31
42
  [0.1.2]: https://github.com/akiomik/scenic-cascade/compare/v0.1.1...v0.1.2
32
43
  [0.1.1]: https://github.com/akiomik/scenic-cascade/compare/v0.1.0...v0.1.1
33
44
  [0.1.0]: https://github.com/akiomik/scenic-cascade/releases/tag/v0.1.0
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- scenic-cascade (0.1.2)
4
+ scenic-cascade (0.1.3)
5
5
  scenic (~> 1.0)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -37,6 +37,12 @@ $ bin/rails generate scenic:view:cascade search_results
37
37
  create db/migrate/20220714233704_create_search_results.rb
38
38
  ```
39
39
 
40
+ For a schema-qualified view, pass its database relation name, such as `reporting.orders`.
41
+ If Rails uses a table name prefix, include it after the schema name (for example, `reporting.app_orders`).
42
+ For unqualified names, pass the name without the prefix; Rails applies it in the migration.
43
+ If another schema precedes `public` in PostgreSQL's `search_path` and contains a view with the same name,
44
+ use a schema-qualified Scenic definition for the public dependent view (for example, `public_reports_v01.sql`).
45
+
40
46
  ## How it works
41
47
 
42
48
  Consider a situation where the following three views exist:
data/codecov.yml ADDED
@@ -0,0 +1,3 @@
1
+ # Release version changes are metadata and do not exercise application behavior.
2
+ ignore:
3
+ - '^lib/scenic/cascade/version\.rb$'
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'erb'
3
4
  require 'generators/scenic/view/view_generator'
4
5
  require 'scenic/cascade'
5
6
  require 'scenic/cascade/view_with_version'
@@ -12,27 +13,83 @@ module Scenic
12
13
  source_root File.expand_path('templates', __dir__ || '.')
13
14
 
14
15
  def create_view_definition
16
+ @definition_path = @original_definition = nil
17
+ validate_update_options! unless destroying? || creating_new_view?
15
18
  dependents unless destroying? || creating_new_view?
19
+ path = Scenic::Definition.new(plural_file_name, version).full_path.to_s
20
+ @definition_path = path
21
+ @original_definition = File.binread(path) if File.file?(path)
16
22
  super
23
+ rescue StandardError
24
+ restore_definition
25
+ raise
17
26
  end
18
27
 
19
28
  def create_migration_file
20
- if creating_new_view? || destroying_initial_view?
21
- migration_template('db/migrate/create_view.rb.erb',
22
- "db/migrate/create_#{plural_file_name}.rb")
23
- else
24
- migration_template('db/migrate/update_view.rb.erb',
25
- "db/migrate/update_#{plural_file_name}_to_version_#{version}.rb")
26
- end
29
+ template, destination = migration_template_details
30
+ pattern = File.join(destination_root, 'db/migrate', "[0-9]*_#{File.basename(destination)}")
31
+ original_migrations = Dir.glob(pattern).to_h { |path| [path, File.binread(path)] }
32
+ migration_template(template, destination)
33
+ # Thor returns normally for --skip even though it did not create a migration.
34
+ restore_definition if options[:skip] && skipped_migration?(template, pattern, original_migrations)
35
+ rescue StandardError
36
+ restore_migrations(pattern, original_migrations) if pattern && original_migrations
37
+ restore_definition
38
+ raise
27
39
  end
28
40
 
29
41
  private
30
42
 
43
+ def validate_update_options!
44
+ raise ArgumentError, '--replace cannot update a materialized view' if options[:replace] && materialized?
45
+
46
+ validate_side_by_side_option! if options[:side_by_side]
47
+ end
48
+
49
+ def validate_side_by_side_option!
50
+ raise ArgumentError, '--side-by-side requires --materialized' unless materialized?
51
+ raise ArgumentError, '--side-by-side cannot update schema-qualified views' if plural_name.include?('.')
52
+
53
+ raise ArgumentError, '--side-by-side cannot be combined with --no-data' if options[:no_data]
54
+ end
55
+
56
+ def migration_template_details
57
+ return ['db/migrate/create_view.rb.erb', "db/migrate/create_#{plural_file_name}.rb"] if
58
+ creating_new_view? || destroying_initial_view?
59
+
60
+ ['db/migrate/update_view.rb.erb', "db/migrate/update_#{plural_file_name}_to_version_#{version}.rb"]
61
+ end
62
+
63
+ def restore_definition
64
+ return unless (path = @definition_path)
65
+
66
+ if (original = @original_definition)
67
+ restore_file(path, original)
68
+ elsif File.file?(path)
69
+ File.delete(path)
70
+ end
71
+ end
72
+
73
+ def restore_migrations(pattern, originals)
74
+ (Dir.glob(pattern) - originals.keys).each { |path| File.delete(path) }
75
+ originals.each { |path, content| restore_file(path, content) }
76
+ end
77
+
78
+ def restore_file(path, content)
79
+ File.binwrite(path, content) unless File.file?(path) && File.binread(path) == content
80
+ end
81
+
82
+ def skipped_migration?(template, pattern, originals)
83
+ return false unless Dir.glob(pattern).to_h { |path| [path, File.binread(path)] } == originals
84
+
85
+ originals.values.first != ERB.new(File.read(find_in_source_paths(template)), trim_mode: '-').result(binding).b
86
+ end
87
+
31
88
  def dependents
32
89
  cached_dependents = @dependents
33
90
  return cached_dependents unless cached_dependents.nil?
34
91
 
35
- deps = Scenic::Cascade.view_dependents_of(plural_name, recursive: true)
92
+ deps = Scenic::Cascade.view_dependents_of(database_view_name, recursive: true)
36
93
  views = deps.map { |dep| view_with_version_for(dep) }
37
94
  # The last visit follows every prerequisite, even when names sort in the opposite order.
38
95
  @dependents = views.reverse.uniq { |view| [view.name, view.version, view.materialized?] }.reverse
@@ -40,11 +97,48 @@ module Scenic
40
97
 
41
98
  def view_with_version_for(dep)
42
99
  name = dep.from.name
43
- definition = Scenic::Cascade.find_definitions_of(name).last
100
+ definition_name = definition_name_for(name)
101
+ definition = Scenic::Cascade.find_definitions_of(definition_name).last
44
102
  raise ArgumentError, "Dependent view #{name} has no Scenic definition file" if definition.nil?
45
103
 
46
- version = definition.version.to_i
47
- Scenic::Cascade::ViewWithVersion.new(name: name, version: version, materialized: dep.from.materialized?)
104
+ Scenic::Cascade::ViewWithVersion.new(
105
+ name: definition_name, version: definition.version.to_i,
106
+ materialized: dep.from.materialized?, database_name: qualified_name(name)
107
+ )
108
+ end
109
+
110
+ def definition_name_for(name)
111
+ return name if name.include?('.')
112
+
113
+ qualified = qualified_name(name)
114
+ qualified_definitions = Scenic::Cascade.find_definitions_of(qualified)
115
+ bare_definitions = Scenic::Cascade.find_definitions_of(name)
116
+
117
+ if qualified_definitions.any? && bare_definitions.any?
118
+ raise ArgumentError, "Dependent view #{name} has ambiguous Scenic definitions: #{name} and #{qualified}"
119
+ end
120
+
121
+ return qualified if qualified_definitions.any?
122
+ return name if bare_definitions.empty? || bare_name_resolves_to_public?(name)
123
+
124
+ raise ArgumentError, "Dependent view #{name} has no unambiguous Scenic definition file for #{qualified}"
125
+ end
126
+
127
+ def bare_name_resolves_to_public?(name)
128
+ connection = ActiveRecord::Base.connection
129
+ bare = connection.quote(connection.quote_table_name(name))
130
+ qualified = connection.quote(connection.quote_table_name(qualified_name(name)))
131
+ connection.select_value("SELECT to_regclass(#{bare}) = to_regclass(#{qualified})")
132
+ end
133
+
134
+ def qualified_name(name)
135
+ name.include?('.') ? name : "public.#{name}"
136
+ end
137
+
138
+ def database_view_name
139
+ return plural_name if plural_name.include?('.')
140
+
141
+ "#{ActiveRecord::Base.table_name_prefix}#{plural_name}#{ActiveRecord::Base.table_name_suffix}"
48
142
  end
49
143
  end
50
144
  end
@@ -1,26 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class <%= migration_class_name %> < <%= activerecord_migration_class %>
4
+ <%- if dependents.any? || plural_name.include?('.') -%>
5
+ class ExactViewName < String
6
+ alias table_name to_s
7
+ end
8
+
9
+ <%- end -%>
10
+ <%- target_name = plural_name.include?('.') ? "ExactViewName.new(#{plural_name.inspect})" : formatted_plural_name -%>
11
+ <%- update_method = options[:replace] ? 'replace_view' : 'update_view' -%>
12
+ <%- materialized_flags = [] -%>
13
+ <%- materialized_flags << 'no_data: true' if options[:no_data] -%>
14
+ <%- materialized_flags << 'side_by_side: true' if options[:side_by_side] -%>
15
+ <%- materialized_options = materialized_flags.empty? ? 'true' : "{ #{materialized_flags.join(', ')} }" -%>
4
16
  def change
5
17
  <%- dependents.reverse_each do |dep| -%>
6
- drop_view <%= dep.formatted_name %>,
18
+ <%- if dep.database_name == dep.name -%>
19
+ drop_view ExactViewName.new(<%= dep.database_name.inspect %>),
7
20
  revert_to_version: <%= dep.version %>,
8
21
  materialized: <%= dep.materialized? %>
22
+ <%- else -%>
23
+ reversible do |direction|
24
+ direction.up do
25
+ drop_view ExactViewName.new(<%= dep.database_name.inspect %>), materialized: <%= dep.materialized? %>
26
+ end
27
+ direction.down do
28
+ create_view ExactViewName.new(<%= dep.database_name.inspect %>),
29
+ sql_definition: Scenic::Definition.new(<%= dep.name.inspect %>, <%= dep.version %>).to_sql,
30
+ materialized: <%= dep.materialized? %>
31
+ end
32
+ end
33
+ <%- end -%>
9
34
  <%- end -%>
10
35
 
11
36
  <%- if materialized? -%>
12
- update_view <%= formatted_plural_name %>,
37
+ <%= update_method %> <%= target_name %>,
13
38
  version: <%= version %>,
14
39
  revert_to_version: <%= previous_version %>,
15
- materialized: <%= no_data? ? "{ no_data: true }" : true %>
40
+ materialized: <%= materialized_options %>
16
41
  <%- else -%>
17
- update_view <%= formatted_plural_name %>, version: <%= version %>, revert_to_version: <%= previous_version %>
42
+ <%= update_method %> <%= target_name %>, version: <%= version %>, revert_to_version: <%= previous_version %>
18
43
  <%- end -%>
19
44
 
20
45
  <%- dependents.each do |dep| -%>
21
- create_view <%= dep.formatted_name %>,
46
+ <%- if dep.database_name == dep.name -%>
47
+ create_view ExactViewName.new(<%= dep.database_name.inspect %>),
22
48
  version: <%= dep.version %>,
23
49
  materialized: <%= dep.materialized? %>
50
+ <%- else -%>
51
+ reversible do |direction|
52
+ direction.up do
53
+ create_view ExactViewName.new(<%= dep.database_name.inspect %>),
54
+ sql_definition: Scenic::Definition.new(<%= dep.name.inspect %>, <%= dep.version %>).to_sql,
55
+ materialized: <%= dep.materialized? %>
56
+ end
57
+ direction.down do
58
+ drop_view ExactViewName.new(<%= dep.database_name.inspect %>), materialized: <%= dep.materialized? %>
59
+ end
60
+ end
61
+ <%- end -%>
24
62
  <%- end -%>
25
63
  end
26
64
  end
@@ -14,21 +14,28 @@ module Scenic
14
14
  module ClassMethods
15
15
  DEPENDENCY_SQL = <<-SQL
16
16
  SELECT DISTINCT
17
- dependee.relname AS to
17
+ CASE WHEN dependee_ns.nspname = 'public' THEN dependee.relname
18
+ ELSE dependee_ns.nspname || '.' || dependee.relname END AS to
18
19
  , dependee.relkind = 'm' AS to_materialized
19
- , depender.relname AS from
20
+ , CASE WHEN depender_ns.nspname = 'public' THEN depender.relname
21
+ ELSE depender_ns.nspname || '.' || depender.relname END AS from
20
22
  , depender.relkind = 'm' AS from_materialized
23
+ , format('%I.%I', dependee_ns.nspname, dependee.relname) AS to_lookup
21
24
  FROM pg_depend d
22
25
  JOIN pg_rewrite r
23
26
  ON d.objid = r.oid
24
27
  JOIN pg_class AS dependee
25
28
  ON d.refobjid = dependee.oid
29
+ JOIN pg_namespace AS dependee_ns
30
+ ON dependee.relnamespace = dependee_ns.oid
26
31
  JOIN pg_class AS depender
27
32
  ON r.ev_class = depender.oid
28
- WHERE depender.relname = ?
29
- AND dependee.relname != depender.relname
33
+ JOIN pg_namespace AS depender_ns
34
+ ON depender.relnamespace = depender_ns.oid
35
+ WHERE depender.oid = to_regclass(?)
36
+ AND dependee.oid != depender.oid
30
37
  AND dependee.relkind in ('m', 'v')
31
- ORDER BY dependee.relname;
38
+ ORDER BY to_lookup;
32
39
  SQL
33
40
 
34
41
  private_constant :DEPENDENCY_SQL
@@ -41,8 +48,8 @@ module Scenic
41
48
  return [] if dependencies.empty?
42
49
  return dependencies unless recursive
43
50
 
44
- dependencies.flat_map do |dependency|
45
- [dependency, *view_dependencies_of(dependency.to.name, recursive: true)]
51
+ raw_dependencies.zip(dependencies).flat_map do |row, dependency|
52
+ [dependency, *view_dependencies_of(row.fetch('to_lookup'), recursive: true)]
46
53
  end
47
54
  end
48
55
  end
@@ -14,21 +14,28 @@ module Scenic
14
14
  module ClassMethods
15
15
  DEPENDANT_SQL = <<-SQL
16
16
  SELECT DISTINCT
17
- dependee.relname AS to
17
+ CASE WHEN dependee_ns.nspname = 'public' THEN dependee.relname
18
+ ELSE dependee_ns.nspname || '.' || dependee.relname END AS to
18
19
  , dependee.relkind = 'm' AS to_materialized
19
- , depender.relname AS from
20
+ , CASE WHEN depender_ns.nspname = 'public' THEN depender.relname
21
+ ELSE depender_ns.nspname || '.' || depender.relname END AS from
20
22
  , depender.relkind = 'm' AS from_materialized
23
+ , format('%I.%I', depender_ns.nspname, depender.relname) AS from_lookup
21
24
  FROM pg_depend d
22
25
  JOIN pg_rewrite r
23
26
  ON d.objid = r.oid
24
27
  JOIN pg_class AS dependee
25
28
  ON d.refobjid = dependee.oid
29
+ JOIN pg_namespace AS dependee_ns
30
+ ON dependee.relnamespace = dependee_ns.oid
26
31
  JOIN pg_class AS depender
27
32
  ON r.ev_class = depender.oid
28
- WHERE dependee.relname = ?
29
- AND depender.relname != dependee.relname
33
+ JOIN pg_namespace AS depender_ns
34
+ ON depender.relnamespace = depender_ns.oid
35
+ WHERE dependee.oid = to_regclass(?)
36
+ AND depender.oid != dependee.oid
30
37
  AND depender.relkind in ('m', 'v')
31
- ORDER BY depender.relname;
38
+ ORDER BY from_lookup;
32
39
  SQL
33
40
 
34
41
  private_constant :DEPENDANT_SQL
@@ -41,8 +48,8 @@ module Scenic
41
48
  return [] if dependencies.empty?
42
49
  return dependencies unless recursive
43
50
 
44
- dependencies.flat_map do |dependency|
45
- [dependency, *view_dependents_of(dependency.from.name, recursive: true)]
51
+ raw_dependencies.zip(dependencies).flat_map do |row, dependency|
52
+ [dependency, *view_dependents_of(row.fetch('from_lookup'), recursive: true)]
46
53
  end
47
54
  end
48
55
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Scenic
4
4
  module Cascade
5
- VERSION = '0.1.2'
5
+ VERSION = '0.1.3'
6
6
  end
7
7
  end
@@ -6,14 +6,16 @@ module Scenic
6
6
  module Cascade
7
7
  # Represents a view with version
8
8
  class ViewWithVersion < Scenic::Cascade::View
9
- attr_reader :version
9
+ attr_reader :version, :database_name
10
10
 
11
- def initialize(name:, version:, materialized:)
11
+ def initialize(name:, version:, materialized:, database_name: name)
12
12
  super(name: name, materialized: materialized)
13
13
 
14
14
  raise TypeError unless version.is_a?(Integer)
15
+ raise TypeError unless database_name.is_a?(String)
15
16
 
16
17
  @version = version
18
+ @database_name = database_name
17
19
  end
18
20
 
19
21
  def ==(other)
@@ -3,14 +3,27 @@ module Scenic
3
3
  module View
4
4
  class CascadeGenerator < Scenic::Generators::ViewGenerator
5
5
  @dependents: Array[Scenic::Cascade::ViewWithVersion]?
6
+ @definition_path: String?
7
+ @original_definition: String?
6
8
 
7
9
  def create_view_definition: -> void
8
10
  def create_migration_file: -> void
9
11
 
10
12
  private
11
13
 
14
+ def validate_update_options!: -> void
15
+ def validate_side_by_side_option!: -> void
12
16
  def dependents: -> Array[Scenic::Cascade::ViewWithVersion]
17
+ def migration_template_details: -> [String, String]
18
+ def restore_definition: -> void
19
+ def restore_migrations: (String, Hash[String, String]) -> void
20
+ def restore_file: (String, String) -> void
21
+ def skipped_migration?: (String, String, Hash[String, String]) -> bool
13
22
  def view_with_version_for: (Scenic::Cascade::Dependency) -> Scenic::Cascade::ViewWithVersion
23
+ def database_view_name: -> String
24
+ def definition_name_for: (String) -> String
25
+ def bare_name_resolves_to_public?: (String) -> bool
26
+ def qualified_name: (String) -> String
14
27
  end
15
28
  end
16
29
  end
@@ -3,12 +3,16 @@ module Scenic
3
3
  class ViewGenerator
4
4
  def self.source_root: (String?) -> void
5
5
  def migration_template: (String, String) -> void
6
+ def destination_root: -> String
7
+ def find_in_source_paths: (String) -> String
8
+ def options: -> Hash[Symbol, untyped]
6
9
 
7
10
  def create_view_definition: -> void
8
11
  def version: -> Integer
9
12
 
10
13
  private
11
14
 
15
+ def materialized?: -> bool
12
16
  def plural_name: -> String
13
17
  def plural_file_name: -> String
14
18
  def creating_new_view?: -> bool
@@ -8,8 +8,9 @@ module Scenic
8
8
 
9
9
  class ViewWithVersion < Scenic::Cascade::View
10
10
  attr_reader version: Integer
11
+ attr_reader database_name: String
11
12
 
12
- def initialize: (name: String, version: Integer, materialized: bool) -> void
13
+ def initialize: (name: String, version: Integer, materialized: bool, ?database_name: String) -> void
13
14
 
14
15
  def ==: (_ViewWithVersionLike) -> bool
15
16
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scenic-cascade
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Akiomi Kamakura
@@ -44,6 +44,7 @@ files:
44
44
  - README.md
45
45
  - Rakefile
46
46
  - Steepfile
47
+ - codecov.yml
47
48
  - docker-compose.yml
48
49
  - lib/generators/scenic/view/cascade/USAGE
49
50
  - lib/generators/scenic/view/cascade/cascade_generator.rb