clevic 0.5.1
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.
- data/History.txt +371 -0
- data/INSTALL +10 -0
- data/Manifest.txt +30 -0
- data/README.txt +94 -0
- data/Rakefile +100 -0
- data/TODO +131 -0
- data/accounts_models.rb +122 -0
- data/bin/clevic +64 -0
- data/lib/active_record/dirty.rb +87 -0
- data/lib/clevic.rb +4 -0
- data/lib/clevic/browser.rb +195 -0
- data/lib/clevic/cache_table.rb +281 -0
- data/lib/clevic/db_options.rb +21 -0
- data/lib/clevic/delegates.rb +383 -0
- data/lib/clevic/extensions.rb +133 -0
- data/lib/clevic/field.rb +181 -0
- data/lib/clevic/item_delegate.rb +62 -0
- data/lib/clevic/model_builder.rb +171 -0
- data/lib/clevic/model_column.rb +23 -0
- data/lib/clevic/search_dialog.rb +77 -0
- data/lib/clevic/table_model.rb +431 -0
- data/lib/clevic/table_view.rb +479 -0
- data/lib/clevic/ui/browser.ui +201 -0
- data/lib/clevic/ui/browser_ui.rb +176 -0
- data/lib/clevic/ui/icon.png +0 -0
- data/lib/clevic/ui/search_dialog.ui +216 -0
- data/lib/clevic/ui/search_dialog_ui.rb +106 -0
- data/sql/accounts.sql +302 -0
- data/sql/times.sql +197 -0
- data/times_models.rb +163 -0
- metadata +93 -0
data/Rakefile
ADDED
@@ -0,0 +1,100 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake/clean'
|
3
|
+
require 'hoe'
|
4
|
+
require 'lib/clevic/version.rb'
|
5
|
+
require 'pathname'
|
6
|
+
|
7
|
+
Hoe.new( 'clevic', Clevic::VERSION ) do |s|
|
8
|
+
s.author = "John Anderson"
|
9
|
+
s.email = "john at semiosix dot com"
|
10
|
+
end
|
11
|
+
|
12
|
+
# generate a _ui.rb filename from a .ui filename
|
13
|
+
def ui_rb_file( ui_file )
|
14
|
+
ui_file.gsub( /\.ui$/, '_ui.rb' )
|
15
|
+
end
|
16
|
+
|
17
|
+
# list of .ui files
|
18
|
+
UI_FILES = FileList.new( 'lib/clevic/ui/*.ui' )
|
19
|
+
CLEAN.include( 'ChangeLog', 'lib/clevic/ui/*.rb' )
|
20
|
+
|
21
|
+
UI_FILES.each do |ui_file|
|
22
|
+
# make tasks to generate _ui.rb files
|
23
|
+
file ui_rb_file( ui_file ) => [ ui_file ] do |t|
|
24
|
+
sh "rbuic4 #{t.prerequisites} -o #{t.name}"
|
25
|
+
end
|
26
|
+
|
27
|
+
# make tasks to start designer when the ui file is named
|
28
|
+
desc "Start Qt designer with #{ui_file}"
|
29
|
+
namespace :ui do |n|
|
30
|
+
task Pathname.new(ui_file).basename.to_s.ext do |t|
|
31
|
+
sh "designer #{ui_file}"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
desc 'Generate all _ui.rb files'
|
37
|
+
task :ui => UI_FILES.map{|x| ui_rb_file( x ) }
|
38
|
+
|
39
|
+
namespace :ui do
|
40
|
+
desc 'Start Qt designer with the argument, or all .ui files.'
|
41
|
+
task :design do |t|
|
42
|
+
ARGV.shift()
|
43
|
+
if ARGV.size == 0
|
44
|
+
# start designer with all ui files
|
45
|
+
sh "designer #{UI_FILES.join(' ')}"
|
46
|
+
else
|
47
|
+
# start designer with all files that match an argument
|
48
|
+
sh "designer #{ ARGV.map{|x| UI_FILES.grep( /\/#{x}/ ) }.join(' ') }"
|
49
|
+
end
|
50
|
+
true
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
desc "Runs Clevic in warning mode, with test databases and debug flag on"
|
55
|
+
task :run => :ui do |t|
|
56
|
+
ARGV.shift()
|
57
|
+
exec "ruby -w -Ilib bin/clevic -D #{ARGV.join(' ')}"
|
58
|
+
end
|
59
|
+
|
60
|
+
desc "Runs Clevic in debug mode, with test databases"
|
61
|
+
task :debug => :ui do |t|
|
62
|
+
ARGV.shift()
|
63
|
+
exec "ruby -w -rdebug -Ilib bin/clevic -D #{ARGV.join(' ')}"
|
64
|
+
end
|
65
|
+
|
66
|
+
desc "irb in this project's context"
|
67
|
+
task :irb do |t|
|
68
|
+
ARGV.shift()
|
69
|
+
ENV['RUBYLIB'] += ":#{File.expand_path('.')}/lib"
|
70
|
+
exec "irb -Ilib -rclevic"
|
71
|
+
end
|
72
|
+
|
73
|
+
# generate tasks for all model definition files
|
74
|
+
MODELS_LIST = FileList.new( '**/*models.rb' )
|
75
|
+
|
76
|
+
def short_model( model_file )
|
77
|
+
Pathname.new( model_file ).basename.to_s.gsub( /_models.rb/, '' )
|
78
|
+
end
|
79
|
+
|
80
|
+
MODELS_LIST.each do |model_file|
|
81
|
+
# generate irb contexts
|
82
|
+
desc "irb with #{model_file}"
|
83
|
+
namespace :irb do
|
84
|
+
task short_model( model_file ) do |t|
|
85
|
+
ARGV.shift()
|
86
|
+
ENV['RUBYLIB'] ||= '.'
|
87
|
+
ENV['RUBYLIB'] += ":#{File.expand_path('.')}/lib"
|
88
|
+
exec "irb -Ilib -rclevic -r#{model_file} -rclevic/db_options.rb"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# generate runs
|
93
|
+
desc "run clevic with #{model_file}"
|
94
|
+
task short_model( model_file ) => :ui do |t|
|
95
|
+
ARGV.shift()
|
96
|
+
exec "ruby -w -Ilib bin/clevic -D #{model_file} #{ARGV.join(' ')}"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
task :package => :ui
|
data/TODO
ADDED
@@ -0,0 +1,131 @@
|
|
1
|
+
Times Ctrl-Shift-" should not copy date if it already exists, and should not copy time if it's a different date.
|
2
|
+
Times Ctrl-Shift-" after it's done, tab doesn't change fields
|
3
|
+
Times look up invoice for project leaves the wrong fields highlighted, and focus in the wrong field.
|
4
|
+
|
5
|
+
Using F4 to open list, and then selecting from the combo and exiting using Return (or tab?) doesn't set the correct value
|
6
|
+
wrap description, and allow Access-style zooming
|
7
|
+
Undo deletes and other commands. Possibly via ActiveRecord callbacks.
|
8
|
+
|
9
|
+
Keep a history of changes, ie xy, new. xy, changed. x,y copied etc.
|
10
|
+
|
11
|
+
OSX
|
12
|
+
---
|
13
|
+
Check that qtruby4 runs on OSX and so does Clevic. It does. Very slowly on Leilani's Mini.
|
14
|
+
|
15
|
+
windows
|
16
|
+
-------
|
17
|
+
Ctrl-; date formatting goes 07--08 instead of 07-Apr-08. But typing the full month will be OK.
|
18
|
+
|
19
|
+
empty database
|
20
|
+
--------------
|
21
|
+
resize fields for first record, while it's being entered. use Qt::ExpandingLineEdit for ComboDelegate? Doesn't exist in Ruby bindings
|
22
|
+
|
23
|
+
editing
|
24
|
+
-------
|
25
|
+
F2 for standard edit, F4 for calendar edit
|
26
|
+
Only move for data_changed if field was exited with tab, not enter.
|
27
|
+
make sure record is saved when changing tabs
|
28
|
+
tooltips for tabs
|
29
|
+
Help in general for new data capture people
|
30
|
+
Help to right of tabs
|
31
|
+
messages for wrong dates etc
|
32
|
+
numeric months
|
33
|
+
|
34
|
+
Combos
|
35
|
+
------
|
36
|
+
shortlist combos by prefix. See Qt Examples.
|
37
|
+
turn on/off smart filters for relational delegates. Like selecting only distincts in the last year.
|
38
|
+
context menu for delegates, ie sort order, last used, etc
|
39
|
+
|
40
|
+
shortcut sets, depending on which OS you're used. use QKeyEvent::matches ( QKeySequence::StandardKey key )
|
41
|
+
|
42
|
+
Doing data capture, sort by id, but unfilter reverts to date/id rather than entry order
|
43
|
+
optional warnings for back-dated entries. Highlighting
|
44
|
+
make sure bsearch easier to install
|
45
|
+
|
46
|
+
ORDER BY allow functions, ie lower(project)
|
47
|
+
handle db errors
|
48
|
+
test with sqlite
|
49
|
+
easier way to run models, search LOAD_PATH
|
50
|
+
|
51
|
+
generate models - DrySQL
|
52
|
+
Ctrl-PgDn to last row in this column. Also extend selection
|
53
|
+
sorting by header. See void QAbstractItemModel::sort ( int column, Qt::SortOrder order = Qt::AscendingOrder )
|
54
|
+
- layoutChanged
|
55
|
+
|
56
|
+
search with acts_as_searchable and hyperestraier
|
57
|
+
acts_as_trashable to undo deletes
|
58
|
+
implement undo of field changes
|
59
|
+
|
60
|
+
cache belongs_to associations, during loading, ie don't affect ability to pick up
|
61
|
+
changes more or less instantly.
|
62
|
+
|
63
|
+
moving of columns
|
64
|
+
/-style keyboard search by selected column, or everything if no column selected
|
65
|
+
/-style filtering?
|
66
|
+
|
67
|
+
for dates, add year if not specified, with 6 months on either side range. Configurable?
|
68
|
+
value formatting not in model
|
69
|
+
copy a field from a mouse-selection (ctrl-b maybe)
|
70
|
+
hiding of fields
|
71
|
+
|
72
|
+
save context menu settings, filter settings, etc
|
73
|
+
filtering by various things. http://doc.trolltech.com/4.3/qsortfilterproxymodel.html
|
74
|
+
highlighting by various things
|
75
|
+
cut and paste (in model and csv format)
|
76
|
+
|
77
|
+
drop cached model objects from CacheTable when they're not in use
|
78
|
+
|
79
|
+
allow scroll viewport to centre when at end of dataset
|
80
|
+
QAbstractItemView::ScrollHint
|
81
|
+
|
82
|
+
\value EnsureVisible Scroll to ensure that the item is visible.
|
83
|
+
\value PositionAtTop Scroll to position the item at the top of the viewport.
|
84
|
+
\value PositionAtBottom Scroll to position the item at the bottom of the viewport.
|
85
|
+
\value PositionAtCenter Scroll to position the item at the center of the viewport.
|
86
|
+
|
87
|
+
|
88
|
+
preferences
|
89
|
+
-----------
|
90
|
+
store previous searches, by model & app
|
91
|
+
|
92
|
+
|
93
|
+
maybe
|
94
|
+
-----
|
95
|
+
use rubigen for creating apps
|
96
|
+
allow moving of rows
|
97
|
+
discontiguous copying of entities/csv
|
98
|
+
multi-row copying
|
99
|
+
pasting of csv, into rectangular regions
|
100
|
+
collect a set of data requests to the model, and do them in one SQL query. See EntryTableView#moveCursor
|
101
|
+
Use SQL cursors for find & find_next?
|
102
|
+
|
103
|
+
Accounts
|
104
|
+
--------
|
105
|
+
paste of "common" records with different dates
|
106
|
+
restricted type for Account Type record
|
107
|
+
|
108
|
+
Times
|
109
|
+
-----
|
110
|
+
|
111
|
+
warnings on overlap times (in status bar)
|
112
|
+
warnings on large intervals (in status bar)
|
113
|
+
|
114
|
+
db
|
115
|
+
--
|
116
|
+
|
117
|
+
times
|
118
|
+
|
119
|
+
alter table invoices rename column type to billing;
|
120
|
+
alter table entries add primary key (id);
|
121
|
+
alter table entries alter column id set not null
|
122
|
+
select max(id) from entries;
|
123
|
+
create sequence entries_id_seq start with 11694;
|
124
|
+
alter table entries alter column id set default nextval('entries_id_seq');
|
125
|
+
update entries set module = null where module = '';
|
126
|
+
update entries set module = 'admin' where module = 'Admin';
|
127
|
+
|
128
|
+
alter table projects rename active to old_active;
|
129
|
+
alter table projects add column active boolean;
|
130
|
+
update projects set active = ( old_active = 1 );
|
131
|
+
alter table projects drop column old_active;
|
data/accounts_models.rb
ADDED
@@ -0,0 +1,122 @@
|
|
1
|
+
require 'clevic.rb'
|
2
|
+
|
3
|
+
# db connection options
|
4
|
+
$options ||= {}
|
5
|
+
$options[:database] ||= $options[:debug] ? 'accounts_test' : 'accounts'
|
6
|
+
$options[:adapter] ||= 'postgresql'
|
7
|
+
$options[:host] ||= 'localhost'
|
8
|
+
$options[:username] ||= 'panic'
|
9
|
+
$options[:password] ||= ''
|
10
|
+
|
11
|
+
class Entry < ActiveRecord::Base
|
12
|
+
include ActiveRecord::Dirty
|
13
|
+
belongs_to :debit, :class_name => 'Account', :foreign_key => 'debit_id'
|
14
|
+
belongs_to :credit, :class_name => 'Account', :foreign_key => 'credit_id'
|
15
|
+
|
16
|
+
# define how fields will be displayed
|
17
|
+
def self.ui( parent )
|
18
|
+
Clevic::TableView.new( self, parent ).create_model do
|
19
|
+
plain :date, :sample => '88-WWW-99'
|
20
|
+
distinct :description, :conditions => "now() - date <= '1 year'", :sample => 'm' * 26, :frequency => true
|
21
|
+
relational :debit, 'name', :class_name => 'Account', :conditions => 'active = true', :order => 'lower(name)', :sample => 'Leilani Member Loan'
|
22
|
+
relational :credit, 'name', :class_name => 'Account', :conditions => 'active = true', :order => 'lower(name)', :sample => 'Leilani Member Loan'
|
23
|
+
plain :amount, :sample => 999999.99
|
24
|
+
distinct :category
|
25
|
+
plain :cheque_number
|
26
|
+
plain :active, :sample => 'WW'
|
27
|
+
plain :vat, :label => 'VAT', :sample => 'WW', :tooltip => 'Does this include VAT?'
|
28
|
+
|
29
|
+
records :order => 'date, id'
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# called when data is changed in the UI
|
34
|
+
def self.data_changed( top_left, bottom_right, view )
|
35
|
+
if top_left == bottom_right
|
36
|
+
update_credit_debit( top_left, view )
|
37
|
+
else
|
38
|
+
puts "top_left: #{top_left.inspect}"
|
39
|
+
puts "bottom_right: #{bottom_right.inspect}"
|
40
|
+
puts "can't do data_changed for a range"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# check that the current field is :descriptions, then
|
45
|
+
# copy the values for the credit and debit fields
|
46
|
+
# from the previous similar entry
|
47
|
+
def self.update_credit_debit( current_index, view )
|
48
|
+
return if !current_index.valid?
|
49
|
+
current_field = current_index.attribute
|
50
|
+
if current_field == :description
|
51
|
+
# most recent entry, ordered in reverse
|
52
|
+
similar = self.find(
|
53
|
+
:first,
|
54
|
+
:conditions => ["#{current_field} = ?", current_index.attribute_value],
|
55
|
+
:order => 'date desc'
|
56
|
+
)
|
57
|
+
if similar != nil
|
58
|
+
# set the values
|
59
|
+
current_index.entity.debit = similar.debit
|
60
|
+
current_index.entity.credit = similar.credit
|
61
|
+
current_index.entity.category = similar.category
|
62
|
+
|
63
|
+
# emit signal to update view from top_left to bottom_right
|
64
|
+
model = current_index.model
|
65
|
+
top_left_index = model.create_index( current_index.row, 0 )
|
66
|
+
bottom_right_index = model.create_index( current_index.row, view.builder.fields.size )
|
67
|
+
view.dataChanged( top_left_index, bottom_right_index )
|
68
|
+
|
69
|
+
# move edit cursor to amount field
|
70
|
+
view.selection_model.clear
|
71
|
+
view.override_next_index( model.create_index( current_index.row, view.builder.index( :amount ) ) )
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
class Account < ActiveRecord::Base
|
78
|
+
include ActiveRecord::Dirty
|
79
|
+
has_many :debits, :class_name => 'Entry', :foreign_key => 'debit_id'
|
80
|
+
has_many :credits, :class_name => 'Entry', :foreign_key => 'credit_id'
|
81
|
+
|
82
|
+
# define how fields are displayed
|
83
|
+
def self.ui( parent )
|
84
|
+
Clevic::TableView.new( self, parent ).create_model do
|
85
|
+
plain :name
|
86
|
+
restricted :vat, :label => 'VAT', :set => %w{ yes no all }
|
87
|
+
plain :account_type
|
88
|
+
plain :pastel_number, :alignment => Qt::AlignRight, :label => 'Pastel'
|
89
|
+
plain :fringe, :format => "%.1f"
|
90
|
+
plain :active
|
91
|
+
|
92
|
+
records :order => 'name,account_type'
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
# order of tab display
|
98
|
+
$options[:models] = [ Entry, Account ]
|
99
|
+
|
100
|
+
# This is a read-only view, which is currently not implemented
|
101
|
+
#~ class Values < ActiveRecord::Base
|
102
|
+
#~ include ActiveRecord::Dirty
|
103
|
+
#~ set_table_name 'values'
|
104
|
+
#~ has_many :debits, :class_name => 'Entry', :foreign_key => 'debit_id'
|
105
|
+
#~ has_many :credits, :class_name => 'Entry', :foreign_key => 'credit_id'
|
106
|
+
#~ def self.ui( parent )
|
107
|
+
#~ Clevic::TableView.new( self, parent ).create_model do
|
108
|
+
#~ readonly
|
109
|
+
#~ plain :date
|
110
|
+
#~ plain :description
|
111
|
+
#~ plain :debit
|
112
|
+
#~ plain :credit
|
113
|
+
#~ plain :pre_vat_amount
|
114
|
+
#~ plain :cheque_number
|
115
|
+
#~ plain :vat, :label => 'VAT'
|
116
|
+
#~ plain :financial_year
|
117
|
+
#~ plain :month
|
118
|
+
|
119
|
+
#~ records :order => 'date'
|
120
|
+
#~ end
|
121
|
+
#~ end
|
122
|
+
#~ end
|
data/bin/clevic
ADDED
@@ -0,0 +1,64 @@
|
|
1
|
+
#! /usr/bin/ruby
|
2
|
+
|
3
|
+
require 'clevic/browser.rb'
|
4
|
+
require 'ruby-debug'
|
5
|
+
|
6
|
+
# fetch command line options
|
7
|
+
require 'optparse'
|
8
|
+
|
9
|
+
# find and require variations on file_path
|
10
|
+
def require_if( file_path )
|
11
|
+
require file_path if File.exist?( file_path ) || File.exist?( file_path + '.rb' )
|
12
|
+
end
|
13
|
+
|
14
|
+
$options = {}
|
15
|
+
oparser = OptionParser.new
|
16
|
+
oparser.on( '-H', '--host HOST', 'RDBMS host', String ) { |o| $options[:host] = o }
|
17
|
+
oparser.on( '-u', '--user USERNAME', String ) { |o| $options[:user] = o }
|
18
|
+
oparser.on( '-p', '--pass PASSWORD', String ) { |o| $options[:password] = o }
|
19
|
+
oparser.on( '-t', '--table TABLE', 'Table to display', String ) { |o| $options[:table] = o }
|
20
|
+
oparser.on( '-d', '--database DATABASE', 'Database name', String ) { |o| $options[:database] = o }
|
21
|
+
oparser.on( '-D', '--debug' ) { |o| $options[:debug] = true }
|
22
|
+
oparser.on( '-v', '--verbose' ) { |o| $options[:verbose] = true }
|
23
|
+
oparser.on( '-h', '-?', '--help' ) do |o|
|
24
|
+
puts oparser.to_s
|
25
|
+
exit( 0 )
|
26
|
+
end
|
27
|
+
|
28
|
+
args = oparser.parse( ARGV )
|
29
|
+
|
30
|
+
if args.size > 0
|
31
|
+
$options[:definition] = args.shift
|
32
|
+
require_if "#{$options[:definition]}_models"
|
33
|
+
require_if $options[:definition]
|
34
|
+
else
|
35
|
+
raise "no model definition file specified"
|
36
|
+
end
|
37
|
+
|
38
|
+
app = Qt::Application.new( args )
|
39
|
+
|
40
|
+
if $options[:debug]
|
41
|
+
require 'pp'
|
42
|
+
#~ puts "$options: #{$options.inspect}"
|
43
|
+
#~ puts args.inspect
|
44
|
+
end
|
45
|
+
|
46
|
+
if !$options.has_key?( :database )
|
47
|
+
raise "Please define $options[:database]"
|
48
|
+
end
|
49
|
+
|
50
|
+
# connect to db
|
51
|
+
require 'clevic/db_options.rb'
|
52
|
+
|
53
|
+
puts "using database #{ActiveRecord::Base.connection.raw_connection.db}" if $options[:debug]
|
54
|
+
|
55
|
+
# show UI
|
56
|
+
main_window = Qt::MainWindow.new
|
57
|
+
browser = Clevic::Browser.new( main_window )
|
58
|
+
browser.open
|
59
|
+
# this must come after Clevic::Browser.new
|
60
|
+
main_window.window_title = $options[:database]
|
61
|
+
main_window.show
|
62
|
+
# make sure any partially edited records are saved when the window is closed
|
63
|
+
app.connect( SIGNAL('lastWindowClosed()') ) { browser.save_all }
|
64
|
+
app.exec
|
@@ -0,0 +1,87 @@
|
|
1
|
+
module ActiveRecord
|
2
|
+
# Track unsaved changes.
|
3
|
+
module Dirty
|
4
|
+
def self.included(base)
|
5
|
+
base.attribute_method_suffix '_changed?', '_change', '_original'
|
6
|
+
base.alias_method_chain :read_attribute, :dirty
|
7
|
+
base.alias_method_chain :write_attribute, :dirty
|
8
|
+
base.alias_method_chain :save, :dirty
|
9
|
+
end
|
10
|
+
|
11
|
+
# Do any attributes have unsaved changes?
|
12
|
+
# person.changed? # => false
|
13
|
+
# person.name = 'bob'
|
14
|
+
# person.changed? # => true
|
15
|
+
def changed?
|
16
|
+
!changed_attributes.empty?
|
17
|
+
end
|
18
|
+
|
19
|
+
# List of attributes with unsaved changes.
|
20
|
+
# person.changed # => []
|
21
|
+
# person.name = 'bob'
|
22
|
+
# person.changed # => ['name']
|
23
|
+
def changed
|
24
|
+
changed_attributes.keys
|
25
|
+
end
|
26
|
+
|
27
|
+
# Map of changed attrs => [original value, new value]
|
28
|
+
# person.changes # => {}
|
29
|
+
# person.name = 'bob'
|
30
|
+
# person.changes # => { 'name' => ['bill', 'bob'] }
|
31
|
+
def changes
|
32
|
+
changed.inject({}) { |h, attr| h[attr] = attribute_change(attr); h }
|
33
|
+
end
|
34
|
+
|
35
|
+
|
36
|
+
# Clear changed attributes after they are saved.
|
37
|
+
def save_with_dirty(*args) #:nodoc:
|
38
|
+
save_without_dirty(*args)
|
39
|
+
ensure
|
40
|
+
changed_attributes.clear
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
# Map of change attr => original value.
|
45
|
+
def changed_attributes
|
46
|
+
@changed_attributes ||= {}
|
47
|
+
end
|
48
|
+
|
49
|
+
|
50
|
+
# Wrap read_attribute to freeze its result.
|
51
|
+
def read_attribute_with_dirty(attr)
|
52
|
+
read_attribute_without_dirty(attr).freeze
|
53
|
+
end
|
54
|
+
|
55
|
+
# Wrap write_attribute to remember original attribute value.
|
56
|
+
def write_attribute_with_dirty(attr, value)
|
57
|
+
attr = attr.to_s
|
58
|
+
|
59
|
+
# The attribute already has an unsaved change.
|
60
|
+
unless changed_attributes.include?(attr)
|
61
|
+
old = read_attribute(attr)
|
62
|
+
|
63
|
+
# Remember the original value if it's different.
|
64
|
+
changed_attributes[attr] = old unless old == value
|
65
|
+
end
|
66
|
+
|
67
|
+
# Carry on.
|
68
|
+
write_attribute_without_dirty(attr, value)
|
69
|
+
end
|
70
|
+
|
71
|
+
|
72
|
+
# Handle *_changed? for method_missing.
|
73
|
+
def attribute_changed?(attr)
|
74
|
+
changed_attributes.include?(attr)
|
75
|
+
end
|
76
|
+
|
77
|
+
# Handle *_change for method_missing.
|
78
|
+
def attribute_change(attr)
|
79
|
+
[changed_attributes[attr], __send__(attr)] if attribute_changed?(attr)
|
80
|
+
end
|
81
|
+
|
82
|
+
# Handle *_original for method_missing.
|
83
|
+
def attribute_original(attr)
|
84
|
+
attribute_changed?(attr) ? changed_attributes[attr] : __send__(attr)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|