Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/generators/scenic/model/model_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/generators/scenic/view/view_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 39 additions & 7 deletions lib/scenic/adapters/postgres.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
38 changes: 36 additions & 2 deletions lib/scenic/adapters/postgres/views.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")

Expand Down
29 changes: 23 additions & 6 deletions lib/scenic/statements.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
19 changes: 16 additions & 3 deletions lib/scenic/view.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions spec/scenic/adapters/postgres/views_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading