diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e678686..aceada27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ changelog, see the [commits] for each version via the version links. [commits]: https://github.com/scenic-views/scenic/commits/master +## [Unreleased] + +### Added + +- Added support for PostgreSQL view options via `with:` hash parameter. Supports + `security_barrier`, `security_invoker`, and `check_option` with an extensible + interface for future PostgreSQL options. + +### Changed + +- **BREAKING**: Custom adapter interface has changed. Adapters must update their + method signatures: + + ```ruby + # Before (Scenic 1.x): + def create_view(name, sql_definition) + def update_view(name, sql_definition) + def replace_view(name, sql_definition) + + # After (Scenic 2.x): + def create_view(name, sql_definition, with: {}) + def update_view(name, sql_definition, with: {}) + def replace_view(name, sql_definition, with: {}) + ``` + + Adapter maintainers can use `**_options` to accept and ignore options if their + database does not support view options, or implement specific option handling + as needed. + ## [1.9.0] - June 30, 2025 [1.9.0]: https://github.com/scenic-views/scenic/compare/v1.8.0...v1.9.0 diff --git a/lib/generators/scenic/model/model_generator.rb b/lib/generators/scenic/model/model_generator.rb index d5796306..55c4540e 100644 --- a/lib/generators/scenic/model/model_generator.rb +++ b/lib/generators/scenic/model/model_generator.rb @@ -8,6 +8,7 @@ module Generators # @api private class ModelGenerator < Rails::Generators::NamedBase include Scenic::Generators::Materializable + source_root File.expand_path("templates", __dir__) def invoke_rails_model_generator diff --git a/lib/generators/scenic/view/view_generator.rb b/lib/generators/scenic/view/view_generator.rb index 555c321d..15133fd6 100644 --- a/lib/generators/scenic/view/view_generator.rb +++ b/lib/generators/scenic/view/view_generator.rb @@ -8,6 +8,7 @@ module Generators class ViewGenerator < Rails::Generators::NamedBase include Rails::Generators::Migration include Scenic::Generators::Materializable + source_root File.expand_path("templates", __dir__) def create_views_directory diff --git a/lib/scenic/adapters/postgres.rb b/lib/scenic/adapters/postgres.rb index b9cd13e3..01ddb404 100644 --- a/lib/scenic/adapters/postgres.rb +++ b/lib/scenic/adapters/postgres.rb @@ -59,10 +59,15 @@ def views # # @param name The name of the view to create # @param sql_definition The SQL schema for the view. + # @param with [Hash] View options to pass to the WITH clause + # @option with [Boolean] :security_barrier Prevents data leakage through query optimizer + # @option with [Boolean] :security_invoker Use invoker's permissions instead of owner's + # @option with [Symbol, String] :check_option (:local or :cascaded) for updatable views # # @return [void] - def create_view(name, sql_definition) - execute "CREATE VIEW #{quote_table_name(name)} AS #{sql_definition};" + def create_view(name, sql_definition, with: {}) + with_clause = build_with_clause(with) + execute "CREATE VIEW #{quote_table_name(name)}#{with_clause} AS #{sql_definition};" end # Updates a view in the database. @@ -79,11 +84,15 @@ def create_view(name, sql_definition) # # @param name The name of the view to update # @param sql_definition The SQL schema for the updated view. + # @param with [Hash] View options to pass to the WITH clause + # @option with [Boolean] :security_barrier Prevents data leakage through query optimizer + # @option with [Boolean] :security_invoker Use invoker's permissions instead of owner's + # @option with [Symbol, String] :check_option (:local or :cascaded) for updatable views # # @return [void] - def update_view(name, sql_definition) + def update_view(name, sql_definition, with: {}) drop_view(name) - create_view(name, sql_definition) + create_view(name, sql_definition, with: with) end # Replaces a view in the database using `CREATE OR REPLACE VIEW`. @@ -105,10 +114,15 @@ def update_view(name, sql_definition) # # @param name The name of the view to update # @param sql_definition The SQL schema for the updated view. + # @param with [Hash] View options to pass to the WITH clause + # @option with [Boolean] :security_barrier Prevents data leakage through query optimizer + # @option with [Boolean] :security_invoker Use invoker's permissions instead of owner's + # @option with [Symbol, String] :check_option (:local or :cascaded) for updatable views # # @return [void] - def replace_view(name, sql_definition) - execute "CREATE OR REPLACE VIEW #{quote_table_name(name)} AS #{sql_definition};" + def replace_view(name, sql_definition, with: {}) + with_clause = build_with_clause(with) + execute "CREATE OR REPLACE VIEW #{quote_table_name(name)}#{with_clause} AS #{sql_definition};" end # Drops the named view from the database @@ -201,7 +215,7 @@ def drop_materialized_view(name) # This is typically called from application code via {Scenic.database}. # # @param name The name of the materialized view to refresh. - # @param concurrently [Boolean] Whether the refreshs hould happen + # @param concurrently [Boolean] Whether the refresh should happen # concurrently or not. A concurrent refresh allows the view to be # refreshed without locking the view for select but requires that the # table have at least one unique index that covers all rows. Attempts to @@ -299,6 +313,24 @@ def refresh_dependencies_for(name, concurrently: false) concurrently: concurrently ) end + + def build_with_clause(options) + return "" if options.empty? + + clauses = options.filter_map do |key, value| + next if value == false || value.nil? + + case value + when true + "#{key} = true" + else + "#{key} = #{value}" + end + end + + return "" if clauses.empty? + " WITH (#{clauses.join(", ")})" + end end end end diff --git a/lib/scenic/adapters/postgres/views.rb b/lib/scenic/adapters/postgres/views.rb index 4c526dd5..d04441af 100644 --- a/lib/scenic/adapters/postgres/views.rb +++ b/lib/scenic/adapters/postgres/views.rb @@ -99,6 +99,7 @@ def views_from_postgres c.relname as viewname, pg_get_viewdef(c.oid) AS definition, c.relkind AS kind, + c.reloptions AS options, n.nspname AS namespace FROM pg_class c LEFT JOIN pg_namespace n ON n.oid = c.relnamespace @@ -112,13 +113,46 @@ def views_from_postgres end def to_scenic_view(result) + namespace, viewname, reloptions = result.values_at("namespace", "viewname", "options") + + options = parse_view_options(reloptions) + + namespaced_viewname = if namespace != "public" + "#{pg_identifier(namespace)}.#{pg_identifier(viewname)}" + else + pg_identifier(viewname) + end + Scenic::View.new( - name: namespaced_view_name(result), + name: namespaced_viewname, definition: result["definition"].strip, - materialized: result["kind"] == "m" + materialized: result["kind"] == "m", + options: options ) end + def parse_view_options(reloptions) + return {} if reloptions.blank? + + options = {} + + reloptions.scan(/(\w+)(?:=(\w+))?/).each do |key, value| + key_sym = key.to_sym + + options[key_sym] = if value.nil? + true + elsif value == "true" + true + elsif value == "false" + false + else + value + end + end + + options + end + def namespaced_view_name(result) namespace, viewname = result.values_at("namespace", "viewname") diff --git a/lib/scenic/statements.rb b/lib/scenic/statements.rb index 14e1f4a5..9ae15bb8 100644 --- a/lib/scenic/statements.rb +++ b/lib/scenic/statements.rb @@ -14,6 +14,10 @@ module Statements # @option materialized [Boolean] :no_data (false) Set to true to create # materialized view without running the associated query. You will need # to perform a non-concurrent refresh to populate with data. + # @param with [Hash] View options to pass to the WITH clause + # @option with [Boolean] :security_barrier Prevents data leakage through query optimizer + # @option with [Boolean] :security_invoker Use invoker's permissions instead of owner's + # @option with [Symbol, String] :check_option (:local or :cascaded) for updatable views # @return The database response from executing the create statement. # # @example Create from `db/views/searches_v02.sql` @@ -24,7 +28,10 @@ module Statements # SELECT * FROM users WHERE users.active = 't' # SQL # - def create_view(name, version: nil, sql_definition: nil, materialized: false) + # @example Create with view options + # create_view(:secure_users, version: 1, with: { security_invoker: true }) + # + def create_view(name, version: nil, sql_definition: nil, materialized: false, with: {}) if version.present? && sql_definition.present? raise( ArgumentError, @@ -47,7 +54,7 @@ def create_view(name, version: nil, sql_definition: nil, materialized: false) no_data: options[:no_data] ) else - Scenic.database.create_view(name, sql_definition) + Scenic.database.create_view(name, sql_definition, with: with) end end @@ -96,12 +103,17 @@ def drop_view(name, revert_to_version: nil, materialized: false) # The view is initially updated with a temporary name and atomically # swapped once it is successfully created with data. Cannot be combined # with the :no_data option. + # @param with [Hash] View options to pass to the WITH clause + # @option with [Boolean] :security_barrier Prevents data leakage through query optimizer + # @option with [Boolean] :security_invoker Use invoker's permissions instead of owner's + # @option with [Symbol, String] :check_option (:local or :cascaded) for updatable views # @return The database response from executing the create statement. # # @example # update_view :engagement_reports, version: 3, revert_to_version: 2 # update_view :comments, version: 2, revert_to_version: 1, materialized: { side_by_side: true } - def update_view(name, version: nil, sql_definition: nil, revert_to_version: nil, materialized: false) + # update_view :secure_users, version: 2, with: { security_invoker: true } + def update_view(name, version: nil, sql_definition: nil, revert_to_version: nil, materialized: false, with: {}) if version.blank? && sql_definition.blank? raise( ArgumentError, @@ -139,7 +151,7 @@ def update_view(name, version: nil, sql_definition: nil, revert_to_version: nil, side_by_side: options[:side_by_side] ) else - Scenic.database.update_view(name, sql_definition) + Scenic.database.update_view(name, sql_definition, with: with) end end @@ -154,12 +166,17 @@ def update_view(name, version: nil, sql_definition: nil, revert_to_version: nil, # @param version [Fixnum] The version number of the view. # @param revert_to_version [Fixnum] The version number to rollback to on # `rake db rollback` + # @param with [Hash] View options to pass to the WITH clause + # @option with [Boolean] :security_barrier Prevents data leakage through query optimizer + # @option with [Boolean] :security_invoker Use invoker's permissions instead of owner's + # @option with [Symbol, String] :check_option (:local or :cascaded) for updatable views # @return The database response from executing the create statement. # # @example # replace_view :engagement_reports, version: 3, revert_to_version: 2 + # replace_view :secure_users, version: 2, with: { security_invoker: true } # - def replace_view(name, version: nil, revert_to_version: nil, materialized: false) + def replace_view(name, version: nil, revert_to_version: nil, materialized: false, with: {}) if version.blank? raise ArgumentError, "version is required" end @@ -170,7 +187,7 @@ def replace_view(name, version: nil, revert_to_version: nil, materialized: false sql_definition = definition(name, version) - Scenic.database.replace_view(name, sql_definition) + Scenic.database.replace_view(name, sql_definition, with: with) end private diff --git a/lib/scenic/view.rb b/lib/scenic/view.rb index 3f39d7ed..a65930ec 100644 --- a/lib/scenic/view.rb +++ b/lib/scenic/view.rb @@ -22,30 +22,43 @@ class View # @return [Boolean] attr_reader :materialized + # Options hash for view WITH clause options + # @return [Hash{Symbol => Object}] + attr_reader :options + # Returns a new instance of View. # # @param name [String] The name of the view. # @param definition [String] The SQL for the query that defines the view. # @param materialized [Boolean] `true` if the view is materialized. - def initialize(name:, definition:, materialized:) + def initialize(name:, definition:, materialized:, options:) @name = name @definition = definition @materialized = materialized + @options = options end # @api private def ==(other) name == other.name && definition == other.definition && - materialized == other.materialized + materialized == other.materialized && + options == other.options end # @api private def to_schema materialized_option = materialized ? "materialized: true, " : "" + with_option = if options.present? && options.any? + with_hash = options.map { |k, v| "#{k}: #{v.inspect}" }.join(", ") + "with: { #{with_hash} }, " + else + "" + end + <<-DEFINITION - create_view #{UnaffixedName.for(name).inspect}, #{materialized_option}sql_definition: <<-\SQL + create_view #{UnaffixedName.for(name).inspect}, #{with_option}#{materialized_option}sql_definition: <<-\SQL #{escaped_definition.indent(2)} SQL DEFINITION diff --git a/spec/scenic/adapters/postgres/views_spec.rb b/spec/scenic/adapters/postgres/views_spec.rb index 677fbc52..370d1e3c 100644 --- a/spec/scenic/adapters/postgres/views_spec.rb +++ b/spec/scenic/adapters/postgres/views_spec.rb @@ -18,6 +18,55 @@ module Adapters expect(first.definition).to eq "SELECT 'Elliot'::text AS name;" end + it "returns scenic view objects with security_barrier option" do + connection = ActiveRecord::Base.connection + connection.execute <<-SQL + CREATE VIEW secure_children WITH (security_barrier) AS SELECT text 'Elliot' AS name + SQL + + views = Postgres::Views.new(connection).all + first = views.first + + expect(views.size).to eq 1 + expect(first.name).to eq "secure_children" + expect(first.materialized).to be false + expect(first.definition).to eq "SELECT 'Elliot'::text AS name;" + expect(first.options[:security_barrier]).to eq true + end + + it "returns scenic view objects with security_invoker option" do + connection = ActiveRecord::Base.connection + connection.execute <<-SQL + CREATE VIEW invoker_children WITH (security_invoker = true) AS SELECT text 'Elliot' AS name + SQL + + views = Postgres::Views.new(connection).all + first = views.first + + expect(views.size).to eq 1 + expect(first.name).to eq "invoker_children" + expect(first.materialized).to be false + expect(first.definition).to eq "SELECT 'Elliot'::text AS name;" + expect(first.options[:security_invoker]).to eq true + end + + it "returns scenic view objects with both security options" do + connection = ActiveRecord::Base.connection + connection.execute <<-SQL + CREATE VIEW secure_invoker_children WITH (security_barrier, security_invoker = true) AS SELECT text 'Elliot' AS name + SQL + + views = Postgres::Views.new(connection).all + first = views.first + + expect(views.size).to eq 1 + expect(first.name).to eq "secure_invoker_children" + expect(first.materialized).to be false + expect(first.definition).to eq "SELECT 'Elliot'::text AS name;" + expect(first.options[:security_barrier]).to eq true + expect(first.options[:security_invoker]).to eq true + end + it "returns scenic view objects for materialized views" do connection = ActiveRecord::Base.connection connection.execute <<-SQL diff --git a/spec/scenic/adapters/postgres_spec.rb b/spec/scenic/adapters/postgres_spec.rb index f95529e1..6c381b4e 100644 --- a/spec/scenic/adapters/postgres_spec.rb +++ b/spec/scenic/adapters/postgres_spec.rb @@ -11,6 +11,34 @@ module Adapters expect(adapter.views.map(&:name)).to include("greetings") end + + it "successfully creates a view with security_barrier" do + adapter = Postgres.new + + adapter.create_view("greetings", "SELECT text 'hi' AS greeting", with: {security_barrier: true}) + + view = adapter.views.find { |v| v.name == "greetings" } + expect(view.options[:security_barrier]).to eq(true) + end + + it "successfully creates a view with security_invoker" do + adapter = Postgres.new + + adapter.create_view("greetings", "SELECT text 'hi' AS greeting", with: {security_invoker: true}) + + view = adapter.views.find { |v| v.name == "greetings" } + expect(view.options[:security_invoker]).to eq(true) + end + + it "successfully creates a view with both security_barrier and security_invoker" do + adapter = Postgres.new + + adapter.create_view("greetings", "SELECT text 'hi' AS greeting", with: {security_barrier: true, security_invoker: true}) + + view = adapter.views.find { |v| v.name == "greetings" } + expect(view.options[:security_barrier]).to eq(true) + expect(view.options[:security_invoker]).to eq(true) + end end describe "#create_materialized_view" do @@ -66,6 +94,19 @@ module Adapters view = adapter.views.first.definition expect(view).to eql "SELECT 'hello'::text AS greeting;" end + + it "successfully replaces a view with security options" do + adapter = Postgres.new + + adapter.create_view("greetings", "SELECT text 'hi' AS greeting") + + adapter.replace_view("greetings", "SELECT text 'hello' AS greeting", with: {security_barrier: true, security_invoker: true}) + + view = adapter.views.find { |v| v.name == "greetings" } + expect(view.definition).to eql "SELECT 'hello'::text AS greeting;" + expect(view.options[:security_barrier]).to eq(true) + expect(view.options[:security_invoker]).to eq(true) + end end describe "#drop_view" do diff --git a/spec/scenic/statements_spec.rb b/spec/scenic/statements_spec.rb index c632f4dd..bd80aeff 100644 --- a/spec/scenic/statements_spec.rb +++ b/spec/scenic/statements_spec.rb @@ -18,19 +18,19 @@ module Scenic connection.create_view :views, version: version expect(Scenic.database).to have_received(:create_view) - .with(:views, definition_stub.to_sql) + .with(:views, definition_stub.to_sql, with: {}) end it "creates a view from a text definition" do - sql_definition = "a defintion" + sql_definition = "a definition" connection.create_view(:views, sql_definition: sql_definition) expect(Scenic.database).to have_received(:create_view) - .with(:views, sql_definition) + .with(:views, sql_definition, with: {}) end - it "creates version 1 of the view if neither version nor sql_defintion are provided" do + it "creates version 1 of the view if neither version nor sql_definition are provided" do version = 1 definition_stub = instance_double("Definition", to_sql: "foo") allow(Definition).to receive(:new) @@ -40,12 +40,39 @@ module Scenic connection.create_view :views expect(Scenic.database).to have_received(:create_view) - .with(:views, definition_stub.to_sql) + .with(:views, definition_stub.to_sql, with: {}) end - it "raises an error if both version and sql_defintion are provided" do + it "creates a view with security_barrier" do + sql_definition = "a definition" + + connection.create_view(:views, sql_definition: sql_definition, with: {security_barrier: true}) + + expect(Scenic.database).to have_received(:create_view) + .with(:views, sql_definition, with: {security_barrier: true}) + end + + it "creates a view with security_invoker" do + sql_definition = "a definition" + + connection.create_view(:views, sql_definition: sql_definition, with: {security_invoker: true}) + + expect(Scenic.database).to have_received(:create_view) + .with(:views, sql_definition, with: {security_invoker: true}) + end + + it "creates a view with both security options" do + sql_definition = "a definition" + + connection.create_view(:views, sql_definition: sql_definition, with: {security_barrier: true, security_invoker: true}) + + expect(Scenic.database).to have_received(:create_view) + .with(:views, sql_definition, with: {security_barrier: true, security_invoker: true}) + end + + it "raises an error if both version and sql_definition are provided" do expect do - connection.create_view :foo, version: 1, sql_definition: "a defintion" + connection.create_view :foo, version: 1, sql_definition: "a definition" end.to raise_error ArgumentError end end @@ -104,16 +131,34 @@ module Scenic connection.update_view(:name, version: 3) expect(Scenic.database).to have_received(:update_view) - .with(:name, definition.to_sql) + .with(:name, definition.to_sql, with: {}) end it "updates a view from a text definition" do - sql_definition = "a defintion" + sql_definition = "a definition" connection.update_view(:name, sql_definition: sql_definition) expect(Scenic.database).to have_received(:update_view) - .with(:name, sql_definition) + .with(:name, sql_definition, with: {}) + end + + it "updates a view with security_barrier" do + sql_definition = "a definition" + + connection.update_view(:name, sql_definition: sql_definition, with: {security_barrier: true}) + + expect(Scenic.database).to have_received(:update_view) + .with(:name, sql_definition, with: {security_barrier: true}) + end + + it "updates a view with security_invoker" do + sql_definition = "a definition" + + connection.update_view(:name, sql_definition: sql_definition, with: {security_invoker: true}) + + expect(Scenic.database).to have_received(:update_view) + .with(:name, sql_definition, with: {security_invoker: true}) end it "updates the materialized view in the database" do @@ -160,19 +205,19 @@ module Scenic .with(:name, definition.to_sql, no_data: false, side_by_side: true) end - it "raises an error if not supplied a version or sql_defintion" do + it "raises an error if not supplied a version or sql_definition" do expect { connection.update_view :views }.to raise_error( ArgumentError, /sql_definition or version must be specified/ ) end - it "raises an error if both version and sql_defintion are provided" do + it "raises an error if both version and sql_definition are provided" do expect do connection.update_view( :views, version: 1, - sql_definition: "a defintion" + sql_definition: "a definition" ) end.to raise_error ArgumentError, /cannot both be set/ end @@ -218,7 +263,31 @@ module Scenic connection.replace_view(:name, version: 3) expect(Scenic.database).to have_received(:replace_view) - .with(:name, definition.to_sql) + .with(:name, definition.to_sql, with: {}) + end + + it "replaces a view with security_barrier" do + definition = instance_double("Definition", to_sql: "definition") + allow(Definition).to receive(:new) + .with(:name, 3) + .and_return(definition) + + connection.replace_view(:name, version: 3, with: {security_barrier: true}) + + expect(Scenic.database).to have_received(:replace_view) + .with(:name, definition.to_sql, with: {security_barrier: true}) + end + + it "replaces a view with security_invoker" do + definition = instance_double("Definition", to_sql: "definition") + allow(Definition).to receive(:new) + .with(:name, 3) + .and_return(definition) + + connection.replace_view(:name, version: 3, with: {security_invoker: true}) + + expect(Scenic.database).to have_received(:replace_view) + .with(:name, definition.to_sql, with: {security_invoker: true}) end it "fails to replace the materialized view in the database" do