Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
Add class methods to restrict which columns are modified in new/set/u…
…pdate, as well as #(set|update)_(all|only|except) to go around them

Model.set_allowed_columns makes it so that only the listed columns are
modified (similar to ActiveRecord's attr_accessible).

Model.set_restricted_columns makes it so that the listed columns are
not modified (similar to ActiveRecord's attr_protected).

Model.unrestrict_primary_key allows you to modify the primary
key in new/set/update.

Model.restrict_primary_key makes the primary key field a
restricted column, and is the default (similar to ActiveRecord).
Being the default, it is only useful in subclasses that have used
unrestrict_primary_key.

Model.restrict_primary_key? returns true/false depending on whether
the primary key is restricted.

Model#(set|update)_(all|only|except ignore the value of
allowed_columns and restricted_columns.  (set|update)_all update
all columns.  (set|update)_only take additional argument(s) specifying
which columns to allow.  (set|update)_except take additional
argument(s) specifying which columns to restrict.  In all cases,
the restrict_primary_key setting remains in effect.

Other setter methods previously callable via new/set/update are no
longer allowed (RESTRICTED_SETTER_METHODS).
  • Loading branch information
jeremyevans committed Jun 15, 2008
1 parent d31a992 commit 7f36a64
Show file tree
Hide file tree
Showing 7 changed files with 385 additions and 69 deletions.
4 changes: 4 additions & 0 deletions sequel/CHANGELOG
@@ -1,5 +1,9 @@
=== HEAD

* The model's primary key is a restricted column by default, Add model.unrestrict_primary_key to get the old behavior (jeremyevans)

* Add Model.set_(allowed|restricted)_columns, which affect which columns create/new/set/update/etc. modify (jeremyevans)

* Calls to Model.def_dataset_method with a block are cached and reapplied to the new dataset if set_dataset is called, even in a subclass (jeremyevans)

* The :reciprocal option to associations should now be the symbol name of the reciprocal association, not an instance variable symbol (jeremyevans)
Expand Down
81 changes: 69 additions & 12 deletions sequel/lib/sequel_model/base.rb
@@ -1,26 +1,35 @@
# This file holds general class methods for Sequel::Model

module Sequel
class Model
# Whether to lazily load the schema for future subclasses. Unless turned
# off, checks the database for the table schema whenever a subclass is
# created
@@lazy_load_schema = false

@allowed_columns = nil
@dataset_methods = {}
@primary_key = :id
@restrict_primary_key = true
@restricted_columns = nil
@typecast_on_assignment = true
@dataset_methods = {}

# Which columns should be the only columns allowed in a call to set
# (default: all columns).
metaattr_accessor :allowed_columns

# Hash of dataset methods to add to this class and subclasses when
# set_dataset is called.
metaattr_accessor :dataset_methods

# The default primary key for classes (default: :id)
metaattr_accessor :primary_key

# Which columns should not be update in a call to set
# (default: no columns).
metaattr_accessor :restricted_columns

# Whether to typecast attribute values on assignment (default: true)
metaattr_accessor :typecast_on_assignment

# Hash of dataset methods to add to this class and subclasses when
# set_dataset is called.
metaattr_accessor :dataset_methods

# Dataset methods to proxy via metaprogramming
DATASET_METHODS = %w'<< all avg count delete distinct eager eager_graph each each_page
empty? except exclude filter first from_self full_outer_join get graph
Expand All @@ -31,6 +40,11 @@ class Model
select_all select_more set set_graph_aliases single_value size to_csv
transform union uniq unordered update where'.map{|x| x.to_sym}

# Instance variables that are inherited in subclasses
INHERITED_INSTANCE_VARIABLES = {:@allowed_columns=>:dup, :@dataset_methods=>:dup,
:@primary_key=>nil, :@restricted_columns=>:dup, :@restrict_primary_key=>nil,
:@typecast_on_assignment=>nil}

# Returns the first record from the database matching the conditions.
# If a hash is given, it is used as the conditions. If another
# object is given, it finds the first record whose primary key(s) match
Expand Down Expand Up @@ -139,14 +153,17 @@ def self.find_or_create(cond)
end

# If possible, set the dataset for the model subclass as soon as it
# is created. Also, inherit the typecast_on_assignment and primary_key
# attributes from the parent class.
# is created. Also, inherit the INHERITED_INSTANCE_VARIABLES
# from the parent class.
def self.inherited(subclass)
sup_class = subclass.superclass
ivs = subclass.instance_variables
subclass.instance_variable_set(:@typecast_on_assignment, sup_class.typecast_on_assignment) unless ivs.include?("@typecast_on_assignment")
subclass.instance_variable_set(:@primary_key, sup_class.primary_key) unless ivs.include?("@primary_key")
subclass.instance_variable_set(:@dataset_methods, sup_class.dataset_methods.dup) unless ivs.include?("@dataset_methods")
INHERITED_INSTANCE_VARIABLES.each do |iv, dup|
next if ivs.include?(iv.to_s)
sup_class_value = sup_class.instance_variable_get(iv)
sup_class_value = sup_class_value.dup if dup == :dup && sup_class_value
subclass.instance_variable_set(iv, sup_class_value)
end
unless ivs.include?("@dataset")
begin
if sup_class == Model
Expand Down Expand Up @@ -203,6 +220,19 @@ def self.primary_key_hash(value)
end
end

# Restrict the setting of the primary key(s) inside new/set/update. Because
# this is the default, this only make sense to use in a subclass where the
# parent class has used unrestrict_primary_key.
def self.restrict_primary_key
@restrict_primary_key = true
end

# Whether or not setting the primary key inside new/set/update is
# restricted, true by default.
def self.restrict_primary_key?
@restrict_primary_key
end

# Serializes column with YAML or through marshalling. Arguments should be
# column symbols, with an optional trailing hash with a :format key
# set to :yaml or :marshal (:yaml is the default). Setting this adds
Expand All @@ -217,6 +247,17 @@ def self.serialize(*columns)
@dataset.transform(@transform) if @dataset
end

# Set the columns to allow in new/set/update. Using this means that
# any columns not listed here will not be modified. If you have any virtual
# setter methods (methods that end in =) that you want to be used in
# new/set/update, they need to be listed here as well (without the =).
#
# It may be better to use (set|update)_only instead of this in places where
# only certain columns may be allowed.
def self.set_allowed_columns(*cols)
@allowed_columns = cols
end

# Sets the dataset associated with the Model class. ds can be a Symbol
# (specifying a table name in the current database), or a Dataset.
# If a dataset is used, the model's database is changed to the given
Expand Down Expand Up @@ -271,6 +312,17 @@ def self.set_primary_key(*key)
@primary_key = (key.length == 1) ? key[0] : key.flatten
end

# Set the columns to restrict in new/set/update. Using this means that
# any columns listed here will not be modified. If you have any virtual
# setter methods (methods that end in =) that you want not to be used in
# new/set/update, they need to be listed here as well (without the =).
#
# It may be better to use (set|update)_except instead of this in places where
# only certain columns may be allowed.
def self.set_restricted_columns(*cols)
@restricted_columns = cols
end

# Makes this model a polymorphic model with the given key being a string
# field in the database holding the name of the class to use. If the
# key given has a NULL value or there are any problems looking up the
Expand Down Expand Up @@ -315,6 +367,11 @@ def self.table_name
dataset.opts[:from].first
end

# Allow the setting of the primary key(s) inside new/set/update.
def self.unrestrict_primary_key
@restrict_primary_key = false
end

# Add model methods that call dataset methods
def_dataset_method(*DATASET_METHODS)

Expand Down
113 changes: 92 additions & 21 deletions sequel/lib/sequel_model/record.rb
@@ -1,7 +1,9 @@
# This file holds general instance methods for Sequel::Model

module Sequel
class Model
# The setter methods (methods ending with =) that are never allowed
# to be called automatically via set.
RESTRICTED_SETTER_METHODS = %w"== === []= taguri= typecast_on_assignment="

# The columns that have been updated. This isn't completely accurate,
# see Model#[]=.
attr_reader :changed_columns
Expand Down Expand Up @@ -42,7 +44,7 @@ def initialize(values = nil, from_db = false, &block)
else
@values = {}
@new = true
set_with_params(values)
set(values)
end
@changed_columns.clear

Expand Down Expand Up @@ -238,24 +240,33 @@ def save_changes
# If no columns have been set for this model (very unlikely), assume symbol
# keys are valid column names, and assign the column value based on that.
def set(hash)
columns_not_set = model.instance_variable_get(:@columns).blank?
meths = setter_methods
hash.each do |k,v|
m = "#{k}="
if meths.include?(m)
send(m, v)
elsif columns_not_set && (Symbol === k)
self[k] = v
end
end
set_restricted(hash, nil, nil)
end
alias_method :set_with_params, :set

# Set all values using the entries in the hash, ignoring any setting of
# allowed_columns or restricted columns in the model.
def set_all(hash)
set_restricted(hash, false, false)
end

# Set all values using the entries in the hash, except for the keys
# given in except.
def set_except(hash, *except)
set_restricted(hash, false, except.flatten)
end

# Set the values using the entries in the hash, only if the key
# is included in only.
def set_only(hash, *only)
set_restricted(hash, only.flatten, false)
end

# Sets the value attributes without saving the record. Returns
# the values changed. Raises an error if the keys are not symbols
# or strings or a string key was passed that was not a valid column.
# This is a low level method that does not respect virtual attributes. It
# should probably be avoided. Look into using set_with_params instead.
# should probably be avoided. Look into using set instead.
def set_values(values)
s = str_columns
vals = values.inject({}) do |m, kv|
Expand Down Expand Up @@ -283,13 +294,30 @@ def this
@this ||= dataset.filter(pk_hash).limit(1).naked
end

# Runs set_with_params and runs save_changes (which runs any callback methods).
def update(values)
set_with_params(values)
save_changes
# Runs set with the passed hash and runs save_changes (which runs any callback methods).
def update(hash)
update_restricted(hash, nil, nil)
end
alias_method :update_with_params, :update

# Update all values using the entries in the hash, ignoring any setting of
# allowed_columns or restricted columns in the model.
def update_all(hash)
update_restricted(hash, false, false)
end

# Update all values using the entries in the hash, except for the keys
# given in except.
def update_except(hash, *except)
update_restricted(hash, false, except.flatten)
end

# Update the values using the entries in the hash, only if the key
# is included in only.
def update_only(hash, *only)
update_restricted(hash, only.flatten, false)
end

# Sets the values attributes with set_values and then updates
# the record in the database using those values. This is a
# low level method that does not run the usual save callbacks.
Expand All @@ -301,10 +329,47 @@ def update_values(values)

private

# Set the columns, filtered by the only and except arrays.
def set_restricted(hash, only, except)
columns_not_set = model.instance_variable_get(:@columns).blank?
meths = setter_methods(only, except)
hash.each do |k,v|
m = "#{k}="
if meths.include?(m)
send(m, v)
elsif columns_not_set && (Symbol === k)
self[k] = v
end
end
end

# Returns all methods that can be used for attribute
# assignment (those that end with =)
def setter_methods
methods.grep(/=\z/)
# assignment (those that end with =), modified by the only
# and except arguments:
#
# * only
# * false - Don't modify the results
# * nil - if the model has allowed_columns, use only these, otherwise, don't modify
# * Array - allow only the given methods to be used
# * except
# * false - Don't modify the results
# * nil - if the model has restricted_columns, remove these, otherwise, don't modify
# * Array - remove the given methods
#
# only takes precedence over except, and if only is not used, certain methods are always
# restricted (RESTRICTED_SETTER_METHODS). The primary key is restricted by default as
# well, see Model.unrestrict_primary_key to change this.
def setter_methods(only, except)
only = only.nil? ? model.allowed_columns : only
except = except.nil? ? model.restricted_columns : except
if only
only.map{|x| "#{x}="}
else
meths = methods.grep(/=\z/) - RESTRICTED_SETTER_METHODS
meths -= Array(primary_key).map{|x| "#{x}="} if primary_key && model.restrict_primary_key?
meths -= except.map{|x| "#{x}="} if except
meths
end
end

# Typecast the value to the column's type if typecasting. Calls the database's
Expand All @@ -315,5 +380,11 @@ def typecast_value(column, value)
raise(Error, "nil/NULL is not allowed for the #{column} column") if value.nil? && (col_schema[:allow_null] == false)
model.db.typecast_value(col_schema[:type], value)
end

# Set the columns, filtered by the only and except arrays.
def update_restricted(hash, only, except)
set_restricted(hash, only, except)
save_changes
end
end
end

0 comments on commit 7f36a64

Please sign in to comment.