rhubarb 0.2.0 → 0.2.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/CHANGES +12 -1
- data/Rakefile +76 -0
- data/TODO +1 -0
- data/VERSION +1 -1
- data/lib/rhubarb/classmixins.rb +274 -0
- data/lib/rhubarb/column.rb +32 -0
- data/lib/rhubarb/mixins/freshness.rb +62 -0
- data/lib/rhubarb/persistence.rb +55 -0
- data/lib/rhubarb/persisting.rb +122 -0
- data/lib/rhubarb/reference.rb +39 -0
- data/lib/rhubarb/rhubarb.rb +6 -529
- data/lib/rhubarb/util.rb +28 -0
- data/ruby-rhubarb.spec +60 -0
- metadata +10 -2
data/CHANGES
CHANGED
@@ -1,4 +1,15 @@
|
|
1
|
-
version 0.2.
|
1
|
+
version 0.2.1
|
2
|
+
|
3
|
+
* Code cleanups
|
4
|
+
|
5
|
+
* Performance improvements
|
6
|
+
|
7
|
+
* Rhubarb now no longer uses explicit transactions internally. I have
|
8
|
+
become convinced that Rhubarb's use of transactions was overly
|
9
|
+
cautious; removing these enables Rhubarb users to use transactions
|
10
|
+
in their own code.
|
11
|
+
|
12
|
+
version 0.2.0 (eecc7cceda993ea9c20cd952aa709ebe470a9f3d)
|
2
13
|
|
3
14
|
* First standalone release of Rhubarb. Previous releases are included
|
4
15
|
with SPQR.
|
data/Rakefile
CHANGED
@@ -1,6 +1,23 @@
|
|
1
1
|
require 'rubygems'
|
2
2
|
require 'rake'
|
3
3
|
|
4
|
+
begin
|
5
|
+
require 'metric_fu'
|
6
|
+
MetricFu::Configuration.run do |config|
|
7
|
+
#define which metrics you want to use
|
8
|
+
config.metrics = [:flog, :flay, :reek, :roodi]
|
9
|
+
config.graphs = [:flog, :flay, :reek, :roodi]
|
10
|
+
config.flay = { :dirs_to_flay => ['lib'],
|
11
|
+
:minimum_score => 10 }
|
12
|
+
config.flog = { :dirs_to_flog => ['lib'] }
|
13
|
+
config.reek = { :dirs_to_reek => ['lib'] }
|
14
|
+
config.roodi = { :dirs_to_roodi => ['lib'] }
|
15
|
+
config.graph_engine = :bluff
|
16
|
+
end
|
17
|
+
rescue LoadError
|
18
|
+
nil
|
19
|
+
end
|
20
|
+
|
4
21
|
begin
|
5
22
|
require 'jeweler'
|
6
23
|
Jeweler::Tasks.new do |gem|
|
@@ -24,6 +41,65 @@ def pkg_version
|
|
24
41
|
return version.chomp
|
25
42
|
end
|
26
43
|
|
44
|
+
def pkg_version
|
45
|
+
version = File.exist?('VERSION') ? File.read('VERSION') : ""
|
46
|
+
return version.chomp
|
47
|
+
end
|
48
|
+
|
49
|
+
def pkg_name
|
50
|
+
return 'ruby-rhubarb'
|
51
|
+
end
|
52
|
+
|
53
|
+
def pkg_spec
|
54
|
+
return pkg_name() + ".spec"
|
55
|
+
end
|
56
|
+
|
57
|
+
def pkg_rel
|
58
|
+
return `grep -i 'define rel' #{pkg_spec} | awk '{print $3}'`.chomp()
|
59
|
+
end
|
60
|
+
|
61
|
+
def pkg_source
|
62
|
+
return pkg_name() + "-" + pkg_version() + "-" + pkg_rel() + ".tar.gz"
|
63
|
+
end
|
64
|
+
|
65
|
+
def pkg_dir
|
66
|
+
return pkg_name() + "-" + pkg_version()
|
67
|
+
end
|
68
|
+
|
69
|
+
def rpm_dirs
|
70
|
+
return %w{BUILD BUILDROOT RPMS SOURCES SPECS SRPMS}
|
71
|
+
end
|
72
|
+
|
73
|
+
desc "create RPMs"
|
74
|
+
task :rpms => [:build, :tarball] do
|
75
|
+
require 'fileutils'
|
76
|
+
FileUtils.cp pkg_spec(), 'SPECS'
|
77
|
+
sh "rpmbuild --define=\"_topdir \${PWD}\" -ba SPECS/#{pkg_spec}"
|
78
|
+
end
|
79
|
+
|
80
|
+
desc "Create a tarball"
|
81
|
+
task :tarball => :make_rpmdirs do
|
82
|
+
require 'fileutils'
|
83
|
+
FileUtils.cp_r 'lib', pkg_dir()
|
84
|
+
FileUtils.cp ['LICENSE', 'README.rdoc', 'CHANGES', 'TODO', 'VERSION'], pkg_dir()
|
85
|
+
sh "tar -cf #{pkg_source} #{pkg_dir}"
|
86
|
+
FileUtils.mv pkg_source(), 'SOURCES'
|
87
|
+
end
|
88
|
+
|
89
|
+
desc "Make dirs for building RPM"
|
90
|
+
task :make_rpmdirs => :rpm_clean do
|
91
|
+
require 'fileutils'
|
92
|
+
FileUtils.mkdir pkg_dir()
|
93
|
+
FileUtils.mkdir rpm_dirs()
|
94
|
+
end
|
95
|
+
|
96
|
+
desc "Cleanup after an RPM build"
|
97
|
+
task :rpm_clean do
|
98
|
+
require 'fileutils'
|
99
|
+
FileUtils.rm_r pkg_dir(), :force => true
|
100
|
+
FileUtils.rm_r rpm_dirs(), :force => true
|
101
|
+
end
|
102
|
+
|
27
103
|
require 'spec/rake/spectask'
|
28
104
|
Spec::Rake::SpecTask.new(:spec) do |spec|
|
29
105
|
spec.libs << 'lib' << 'spec'
|
data/TODO
CHANGED
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.2.
|
1
|
+
0.2.1
|
@@ -0,0 +1,274 @@
|
|
1
|
+
# Class mixins for persisting classes. Part of Rhubarb.
|
2
|
+
#
|
3
|
+
# Copyright (c) 2009--2010 Red Hat, Inc.
|
4
|
+
#
|
5
|
+
# Author: William Benton (willb@redhat.com)
|
6
|
+
#
|
7
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
8
|
+
# you may not use this file except in compliance with the License.
|
9
|
+
# You may obtain a copy of the License at
|
10
|
+
#
|
11
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
12
|
+
|
13
|
+
require 'rhubarb/mixins/freshness'
|
14
|
+
|
15
|
+
module Rhubarb
|
16
|
+
# Methods mixed in to the class object of a persisting class
|
17
|
+
module PersistingClassMixins
|
18
|
+
# Returns the name of the database table modeled by this class.
|
19
|
+
# Defaults to the name of the class (sans module names)
|
20
|
+
def table_name
|
21
|
+
@table_name ||= self.name.split("::").pop.downcase
|
22
|
+
end
|
23
|
+
|
24
|
+
# Enables setting the table name to a custom name
|
25
|
+
def declare_table_name(nm)
|
26
|
+
@table_name = nm
|
27
|
+
end
|
28
|
+
|
29
|
+
alias table_name= declare_table_name
|
30
|
+
|
31
|
+
# Models a foreign-key relationship. +options+ is a hash of options, which include
|
32
|
+
# +:column => + _name_:: specifies the name of the column to reference in +klass+ (defaults to row id)
|
33
|
+
# +:on_delete => :cascade+:: specifies that deleting the referenced row in +klass+ will delete all rows referencing that row through this reference
|
34
|
+
def references(table, options={})
|
35
|
+
Reference.new(table, options)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Models a CHECK constraint.
|
39
|
+
def check(condition)
|
40
|
+
"check (#{condition})"
|
41
|
+
end
|
42
|
+
|
43
|
+
# Returns an object corresponding to the row with the given ID, or +nil+ if no such row exists.
|
44
|
+
def find(id)
|
45
|
+
tup = self.find_tuple(id)
|
46
|
+
tup ? self.new(tup) : nil
|
47
|
+
end
|
48
|
+
|
49
|
+
alias find_by_id find
|
50
|
+
|
51
|
+
def find_by(arg_hash)
|
52
|
+
arg_hash = arg_hash.dup
|
53
|
+
valid_cols = self.colnames.intersection arg_hash.keys
|
54
|
+
select_criteria = valid_cols.map {|col| "#{col.to_s} = #{col.inspect}"}.join(" AND ")
|
55
|
+
arg_hash.each {|k,v| arg_hash[k] = v.row_id if v.respond_to? :row_id}
|
56
|
+
|
57
|
+
self.db.execute("select * from #{table_name} where #{select_criteria} order by row_id", arg_hash).map {|tup| self.new(tup) }
|
58
|
+
end
|
59
|
+
|
60
|
+
# Does what it says on the tin. Since this will allocate an object for each row, it isn't recomended for huge tables.
|
61
|
+
def find_all
|
62
|
+
self.db.execute("SELECT * from #{table_name}").map {|tup| self.new(tup)}
|
63
|
+
end
|
64
|
+
|
65
|
+
def delete_all
|
66
|
+
self.db.execute("DELETE from #{table_name}")
|
67
|
+
end
|
68
|
+
|
69
|
+
# Declares a query method named +name+ and adds it to this class. The query method returns a list of objects corresponding to the rows returned by executing "+SELECT * FROM+ _table_ +WHERE+ _query_" on the database.
|
70
|
+
def declare_query(name, query)
|
71
|
+
declare_custom_query(name, "select * from __TABLE__ where #{query}")
|
72
|
+
end
|
73
|
+
|
74
|
+
# Declares a custom query method named +name+, and adds it to this class. The custom query method returns a list of objects corresponding to the rows returned by executing +query+ on the database. +query+ should select all fields (with +SELECT *+). If +query+ includes the string +\_\_TABLE\_\_+, it will be expanded to the table name. Typically, you will want to use +declare\_query+ instead; this method is most useful for self-joins.
|
75
|
+
def declare_custom_query(name, query)
|
76
|
+
klass = (class << self; self end)
|
77
|
+
klass.class_eval do
|
78
|
+
define_method name.to_s do |*args|
|
79
|
+
# handle reference parameters
|
80
|
+
args = args.map {|arg| Util::rhubarb_fk_identity(arg)}
|
81
|
+
|
82
|
+
res = self.db.execute(query.gsub("__TABLE__", "#{self.table_name}"), args)
|
83
|
+
res.map {|row| self.new(row) }
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def declare_index_on(*fields)
|
89
|
+
@creation_callbacks << Proc.new do
|
90
|
+
idx_name = "idx_#{self.table_name}__#{fields.join('__')}__#{@creation_callbacks.size}"
|
91
|
+
creation_cmd = "create index #{idx_name} on #{self.table_name} (#{fields.join(', ')})"
|
92
|
+
self.db.execute(creation_cmd)
|
93
|
+
end if fields.size > 0
|
94
|
+
end
|
95
|
+
|
96
|
+
# Adds a column named +cname+ to this table declaration, and adds the following methods to the class:
|
97
|
+
# * accessors for +cname+, called +cname+ and +cname=+
|
98
|
+
# * +find\_by\_cname+ and +find\_first\_by\_cname+ methods, which return a list of rows and the first row that have the given value for +cname+, respectively
|
99
|
+
# If the column references a column in another table (given via a +references(...)+ argument to +quals+), then add triggers to the database to ensure referential integrity and cascade-on-delete (if specified)
|
100
|
+
def declare_column(cname, kind, *quals)
|
101
|
+
ensure_accessors
|
102
|
+
|
103
|
+
find_method_name = "find_by_#{cname}".to_sym
|
104
|
+
find_first_method_name = "find_first_by_#{cname}".to_sym
|
105
|
+
find_query = "select * from #{table_name} where #{cname} = ?"
|
106
|
+
|
107
|
+
get_method_name = "#{cname}".to_sym
|
108
|
+
set_method_name = "#{cname}=".to_sym
|
109
|
+
|
110
|
+
# does this column reference another table?
|
111
|
+
rf = quals.find {|q| q.class == Reference}
|
112
|
+
if rf
|
113
|
+
self.refs[cname] = rf
|
114
|
+
end
|
115
|
+
|
116
|
+
# add a find for this column (a class method)
|
117
|
+
klass = (class << self; self end)
|
118
|
+
klass.class_eval do
|
119
|
+
define_method find_method_name do |arg|
|
120
|
+
res = self.db.execute(find_query, arg)
|
121
|
+
res.map {|row| self.new(row)}
|
122
|
+
end
|
123
|
+
|
124
|
+
define_method find_first_method_name do |arg|
|
125
|
+
res = self.db.get_first_row(find_query, arg)
|
126
|
+
res ? self.new(res) : nil
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
self.colnames.merge([cname])
|
131
|
+
self.columns << Column.new(cname, kind, quals)
|
132
|
+
|
133
|
+
# add accessors
|
134
|
+
define_method get_method_name do
|
135
|
+
freshen
|
136
|
+
return @tuple["#{cname}"] if @tuple
|
137
|
+
nil
|
138
|
+
end
|
139
|
+
|
140
|
+
if not rf
|
141
|
+
define_method set_method_name do |arg|
|
142
|
+
@tuple["#{cname}"] = arg
|
143
|
+
update cname, arg
|
144
|
+
end
|
145
|
+
else
|
146
|
+
# this column references another table; create a set
|
147
|
+
# method that can handle either row objects or row IDs
|
148
|
+
define_method set_method_name do |arg|
|
149
|
+
freshen
|
150
|
+
|
151
|
+
arg_id = nil
|
152
|
+
|
153
|
+
if arg.class == Fixnum
|
154
|
+
arg_id = arg
|
155
|
+
arg = rf.referent.find arg_id
|
156
|
+
else
|
157
|
+
arg_id = arg.row_id
|
158
|
+
end
|
159
|
+
@tuple["#{cname}"] = arg
|
160
|
+
|
161
|
+
update cname, arg_id
|
162
|
+
end
|
163
|
+
|
164
|
+
# Finally, add appropriate triggers to ensure referential integrity.
|
165
|
+
# If rf has an on_delete trigger, also add the necessary
|
166
|
+
# triggers to cascade deletes.
|
167
|
+
# Note that we do not support update triggers, since the API does
|
168
|
+
# not expose the capacity to change row IDs.
|
169
|
+
|
170
|
+
rtable = rf.referent.table_name
|
171
|
+
|
172
|
+
self.creation_callbacks << Proc.new do
|
173
|
+
@ccount ||= 0
|
174
|
+
|
175
|
+
insert_trigger_name, delete_trigger_name = %w{insert delete}.map {|op| "ri_#{op}_#{self.table_name}_#{@ccount}_#{rtable}" }
|
176
|
+
|
177
|
+
self.db.execute_batch("CREATE TRIGGER #{insert_trigger_name} BEFORE INSERT ON \"#{self.table_name}\" WHEN new.\"#{cname}\" IS NOT NULL AND NOT EXISTS (SELECT 1 FROM \"#{rtable}\" WHERE new.\"#{cname}\" == \"#{rf.column}\") BEGIN SELECT RAISE(ABORT, 'constraint #{insert_trigger_name} (#{rtable} missing foreign key row) failed'); END;")
|
178
|
+
|
179
|
+
self.db.execute_batch("CREATE TRIGGER #{delete_trigger_name} BEFORE DELETE ON \"#{rtable}\" WHEN EXISTS (SELECT 1 FROM \"#{self.table_name}\" WHERE old.\"#{rf.column}\" == \"#{cname}\") BEGIN DELETE FROM \"#{self.table_name}\" WHERE \"#{cname}\" = old.\"#{rf.column}\"; END;") if rf.options[:on_delete] == :cascade
|
180
|
+
|
181
|
+
@ccount = @ccount + 1
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
# Declares a constraint. Only check constraints are supported; see
|
187
|
+
# the check method.
|
188
|
+
def declare_constraint(cname, kind, *details)
|
189
|
+
ensure_accessors
|
190
|
+
@constraints << "constraint #{cname} #{kind} #{details.join(" ")}"
|
191
|
+
end
|
192
|
+
|
193
|
+
# Creates a new row in the table with the supplied column values.
|
194
|
+
# May throw a SQLite3::SQLException.
|
195
|
+
def create(*args)
|
196
|
+
new_row = args[0]
|
197
|
+
new_row[:created] = new_row[:updated] = Util::timestamp
|
198
|
+
|
199
|
+
cols = colnames.intersection new_row.keys
|
200
|
+
colspec, valspec = [:to_s, :inspect].map {|msg| (cols.map {|col| col.send(msg)}).join(", ")}
|
201
|
+
res = nil
|
202
|
+
|
203
|
+
# resolve any references in the args
|
204
|
+
new_row.each do |column,value|
|
205
|
+
new_row[column] = Util::rhubarb_fk_identity(value)
|
206
|
+
end
|
207
|
+
|
208
|
+
stmt = "insert into #{table_name} (#{colspec}) values (#{valspec})"
|
209
|
+
db.execute(stmt, new_row)
|
210
|
+
res = find(db.last_insert_row_id)
|
211
|
+
|
212
|
+
res
|
213
|
+
end
|
214
|
+
|
215
|
+
# Returns a string consisting of the DDL statement to create a table
|
216
|
+
# corresponding to this class.
|
217
|
+
def table_decl
|
218
|
+
ddlspecs = [columns.join(", "), constraints.join(", ")].reject {|str| str.size==0}.join(", ")
|
219
|
+
"create table #{table_name} (#{ddlspecs});"
|
220
|
+
end
|
221
|
+
|
222
|
+
# Creates a table in the database corresponding to this class.
|
223
|
+
def create_table(dbkey=:default)
|
224
|
+
self.db ||= Persistence::dbs[dbkey] unless @explicitdb
|
225
|
+
self.db.execute(table_decl)
|
226
|
+
@creation_callbacks.each {|func| func.call}
|
227
|
+
end
|
228
|
+
|
229
|
+
def db
|
230
|
+
@db || Persistence::db
|
231
|
+
end
|
232
|
+
|
233
|
+
def db=(dbo)
|
234
|
+
@explicitdb = true
|
235
|
+
@db = dbo
|
236
|
+
end
|
237
|
+
|
238
|
+
# Ensure that all the necessary accessors on our class instance are defined
|
239
|
+
# and that all metaclass fields have the appropriate values
|
240
|
+
def ensure_accessors
|
241
|
+
# Define singleton accessors
|
242
|
+
if not self.respond_to? :columns
|
243
|
+
class << self
|
244
|
+
# Arrays of columns, column names, and column constraints.
|
245
|
+
# Note that colnames does not contain id, created, or updated.
|
246
|
+
# The API purposefully does not expose the ability to create a
|
247
|
+
# row with a given id, and created and updated values are
|
248
|
+
# maintained automatically by the API.
|
249
|
+
attr_accessor :columns, :colnames, :constraints, :dirtied, :refs, :creation_callbacks
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
# Ensure singleton fields are initialized
|
254
|
+
self.columns ||= [Column.new(:row_id, :integer, [:primary_key]), Column.new(:created, :integer, []), Column.new(:updated, :integer, [])]
|
255
|
+
self.colnames ||= Set.new [:created, :updated]
|
256
|
+
self.constraints ||= []
|
257
|
+
self.dirtied ||= {}
|
258
|
+
self.refs ||= {}
|
259
|
+
self.creation_callbacks ||= []
|
260
|
+
end
|
261
|
+
|
262
|
+
# Returns the number of rows in the table backing this class
|
263
|
+
def count
|
264
|
+
self.db.get_first_value("select count(row_id) from #{table_name}").to_i
|
265
|
+
end
|
266
|
+
|
267
|
+
def find_tuple(id)
|
268
|
+
self.db.get_first_row("select * from #{table_name} where row_id = ?", id)
|
269
|
+
end
|
270
|
+
|
271
|
+
include FindFreshest
|
272
|
+
|
273
|
+
end
|
274
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# Column abstraction for Rhubarb.
|
2
|
+
#
|
3
|
+
# Copyright (c) 2009--2010 Red Hat, Inc.
|
4
|
+
#
|
5
|
+
# Author: William Benton (willb@redhat.com)
|
6
|
+
#
|
7
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
8
|
+
# you may not use this file except in compliance with the License.
|
9
|
+
# You may obtain a copy of the License at
|
10
|
+
#
|
11
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
12
|
+
|
13
|
+
module Rhubarb
|
14
|
+
class Column
|
15
|
+
attr_reader :name
|
16
|
+
|
17
|
+
def initialize(name, kind, quals)
|
18
|
+
@name, @kind = name, kind
|
19
|
+
@quals = quals.map {|x| x.to_s.gsub("_", " ") if x.class == Symbol}
|
20
|
+
@quals.map
|
21
|
+
end
|
22
|
+
|
23
|
+
def to_s
|
24
|
+
qualifiers = @quals.join(" ")
|
25
|
+
if qualifiers == ""
|
26
|
+
"#@name #@kind"
|
27
|
+
else
|
28
|
+
"#@name #@kind #{qualifiers}"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# Freshness-based queries ("find_freshest") for Rhubarb classes.
|
2
|
+
#
|
3
|
+
# Copyright (c) 2009--2010 Red Hat, Inc.
|
4
|
+
#
|
5
|
+
# Author: William Benton (willb@redhat.com)
|
6
|
+
#
|
7
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
8
|
+
# you may not use this file except in compliance with the License.
|
9
|
+
# You may obtain a copy of the License at
|
10
|
+
#
|
11
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
12
|
+
|
13
|
+
module Rhubarb
|
14
|
+
module FindFreshest
|
15
|
+
# args contains the following keys
|
16
|
+
# * :group_by maps to a list of columns to group by (mandatory)
|
17
|
+
# * :select_by maps to a hash mapping from column symbols to values (optional)
|
18
|
+
# * :version maps to some version to be considered "current" for the purposes of this query; that is, all rows later than the "current" version will be disregarded (optional, defaults to latest version)
|
19
|
+
def find_freshest(args)
|
20
|
+
args = args.dup
|
21
|
+
|
22
|
+
args[:version] ||= Util::timestamp
|
23
|
+
args[:select_by] ||= {}
|
24
|
+
|
25
|
+
query_params = {}
|
26
|
+
query_params[:version] = args[:version]
|
27
|
+
|
28
|
+
select_clauses = ["created <= :version"]
|
29
|
+
|
30
|
+
valid_cols = self.colnames.intersection args[:select_by].keys
|
31
|
+
|
32
|
+
valid_cols.map do |col|
|
33
|
+
select_clauses << "#{col.to_s} = #{col.inspect}"
|
34
|
+
val = args[:select_by][col]
|
35
|
+
val = val.row_id if val.respond_to? :row_id
|
36
|
+
query_params[col] = val
|
37
|
+
end
|
38
|
+
|
39
|
+
group_by_clause = "GROUP BY " + args[:group_by].join(", ")
|
40
|
+
where_clause = "WHERE " + select_clauses.join(" AND ")
|
41
|
+
projection = self.colnames - [:created]
|
42
|
+
join_clause = projection.map do |column|
|
43
|
+
"__fresh.#{column} = __freshest.#{column}"
|
44
|
+
end
|
45
|
+
|
46
|
+
projection << "MAX(created) AS __current_version"
|
47
|
+
join_clause << "__fresh.__current_version = __freshest.created"
|
48
|
+
|
49
|
+
query = "
|
50
|
+
SELECT __freshest.* FROM (
|
51
|
+
SELECT #{projection.to_a.join(', ')} FROM (
|
52
|
+
SELECT * from #{table_name} #{where_clause}
|
53
|
+
) #{group_by_clause}
|
54
|
+
) as __fresh INNER JOIN #{table_name} as __freshest ON
|
55
|
+
#{join_clause.join(' AND ')}
|
56
|
+
ORDER BY row_id
|
57
|
+
"
|
58
|
+
|
59
|
+
self.db.execute(query, query_params).map {|tup| self.new(tup) }
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# Database interface for Rhubarb
|
2
|
+
#
|
3
|
+
# Copyright (c) 2009--2010 Red Hat, Inc.
|
4
|
+
#
|
5
|
+
# Author: William Benton (willb@redhat.com)
|
6
|
+
#
|
7
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
8
|
+
# you may not use this file except in compliance with the License.
|
9
|
+
# You may obtain a copy of the License at
|
10
|
+
#
|
11
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
12
|
+
|
13
|
+
module Rhubarb
|
14
|
+
module Persistence
|
15
|
+
class DbCollection < Hash
|
16
|
+
alias orig_set []=
|
17
|
+
|
18
|
+
def []=(key,db)
|
19
|
+
setup_db(db) if db
|
20
|
+
orig_set(key,db)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
def setup_db(db)
|
25
|
+
db.results_as_hash = true
|
26
|
+
db.type_translation = true
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
@dbs = DbCollection.new
|
31
|
+
|
32
|
+
def self.open(filename, which=:default)
|
33
|
+
dbs[which] = SQLite3::Database.new(filename)
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.close(which=:default)
|
37
|
+
if dbs[which]
|
38
|
+
dbs[which].close
|
39
|
+
dbs.delete(which)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.db
|
44
|
+
dbs[:default]
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.db=(d)
|
48
|
+
dbs[:default] = d
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.dbs
|
52
|
+
@dbs
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
# Instance mixins for persisting classes. Part of Rhubarb.
|
2
|
+
#
|
3
|
+
# Copyright (c) 2009--2010 Red Hat, Inc.
|
4
|
+
#
|
5
|
+
# Author: William Benton (willb@redhat.com)
|
6
|
+
#
|
7
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
8
|
+
# you may not use this file except in compliance with the License.
|
9
|
+
# You may obtain a copy of the License at
|
10
|
+
#
|
11
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
12
|
+
|
13
|
+
module Rhubarb
|
14
|
+
|
15
|
+
module Persisting
|
16
|
+
def self.included(other)
|
17
|
+
class << other
|
18
|
+
include PersistingClassMixins
|
19
|
+
end
|
20
|
+
|
21
|
+
other.class_eval do
|
22
|
+
attr_reader :row_id
|
23
|
+
attr_reader :created
|
24
|
+
attr_reader :updated
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def db
|
29
|
+
self.class.db
|
30
|
+
end
|
31
|
+
|
32
|
+
# Returns true if the row backing this object has been deleted from the database
|
33
|
+
def deleted?
|
34
|
+
freshen
|
35
|
+
not @tuple
|
36
|
+
end
|
37
|
+
|
38
|
+
# Initializes a new instance backed by a tuple of values. Do not call this directly.
|
39
|
+
# Create new instances with the create or find methods.
|
40
|
+
def initialize(tup)
|
41
|
+
@backed = true
|
42
|
+
@tuple = tup
|
43
|
+
mark_fresh
|
44
|
+
@row_id = @tuple["row_id"]
|
45
|
+
@created = @tuple["created"]
|
46
|
+
@updated = @tuple["updated"]
|
47
|
+
resolve_referents
|
48
|
+
self.class.dirtied[@row_id] ||= @expired_after
|
49
|
+
self
|
50
|
+
end
|
51
|
+
|
52
|
+
# Deletes the row corresponding to this object from the database;
|
53
|
+
# invalidates =self= and any other objects backed by this row
|
54
|
+
def delete
|
55
|
+
self.db.execute("delete from #{self.class.table_name} where row_id = ?", @row_id)
|
56
|
+
mark_dirty
|
57
|
+
@tuple = nil
|
58
|
+
@row_id = nil
|
59
|
+
end
|
60
|
+
|
61
|
+
## Begin private methods
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
# Fetches updated attribute values from the database if necessary
|
66
|
+
def freshen
|
67
|
+
if needs_refresh?
|
68
|
+
@tuple = self.class.find_tuple(@row_id)
|
69
|
+
if @tuple
|
70
|
+
@updated = @tuple["updated"]
|
71
|
+
else
|
72
|
+
@row_id = @updated = @created = nil
|
73
|
+
end
|
74
|
+
mark_fresh
|
75
|
+
resolve_referents
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# True if the underlying row in the database is inconsistent with the state
|
80
|
+
# of this object, whether because the row has changed, or because this object has no row id
|
81
|
+
def needs_refresh?
|
82
|
+
if not @row_id
|
83
|
+
@tuple != nil
|
84
|
+
else
|
85
|
+
@expired_after < self.class.dirtied[@row_id]
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# Mark this row as dirty so that any other objects backed by this row will
|
90
|
+
# update from the database before their attributes are inspected
|
91
|
+
def mark_dirty
|
92
|
+
self.class.dirtied[@row_id] = Util::timestamp
|
93
|
+
end
|
94
|
+
|
95
|
+
# Mark this row as consistent with the underlying database as of now
|
96
|
+
def mark_fresh
|
97
|
+
@expired_after = Util::timestamp
|
98
|
+
end
|
99
|
+
|
100
|
+
# Helper method to update the row in the database when one of our fields changes
|
101
|
+
def update(attr_name, value)
|
102
|
+
mark_dirty
|
103
|
+
self.db.execute("update #{self.class.table_name} set #{attr_name} = ?, updated = ? where row_id = ?", value, Util::timestamp, @row_id)
|
104
|
+
end
|
105
|
+
|
106
|
+
# Resolve any fields that reference other tables, replacing row ids with referred objects
|
107
|
+
def resolve_referents
|
108
|
+
refs = self.class.refs
|
109
|
+
|
110
|
+
refs.each do |c,r|
|
111
|
+
c = c.to_s
|
112
|
+
if r.referent == self.class and @tuple[c] == row_id
|
113
|
+
@tuple[c] = self
|
114
|
+
else
|
115
|
+
row = r.referent.find @tuple[c]
|
116
|
+
@tuple[c] = row if row
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# Foreign-key abstraction for Rhubarb.
|
2
|
+
#
|
3
|
+
# Copyright (c) 2009--2010 Red Hat, Inc.
|
4
|
+
#
|
5
|
+
# Author: William Benton (willb@redhat.com)
|
6
|
+
#
|
7
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
8
|
+
# you may not use this file except in compliance with the License.
|
9
|
+
# You may obtain a copy of the License at
|
10
|
+
#
|
11
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
12
|
+
|
13
|
+
module Rhubarb
|
14
|
+
class Reference
|
15
|
+
attr_reader :referent, :column, :options
|
16
|
+
|
17
|
+
# Creates a new Reference object, modeling a foreign-key relationship to another table. +klass+ is a class that includes Persisting; +options+ is a hash of options, which include
|
18
|
+
# +:column => + _name_:: specifies the name of the column to reference in +klass+ (defaults to row id)
|
19
|
+
# +:on_delete => :cascade+:: specifies that deleting the referenced row in +klass+ will delete all rows referencing that row through this reference
|
20
|
+
def initialize(klass, options={})
|
21
|
+
@referent = klass
|
22
|
+
@options = options
|
23
|
+
@options[:column] ||= "row_id"
|
24
|
+
@column = options[:column]
|
25
|
+
end
|
26
|
+
|
27
|
+
def to_s
|
28
|
+
trigger = ""
|
29
|
+
trigger = " on delete cascade" if options[:on_delete] == :cascade
|
30
|
+
"references #{@referent}(#{@column})#{trigger}"
|
31
|
+
end
|
32
|
+
|
33
|
+
def managed_ref?
|
34
|
+
# XXX?
|
35
|
+
return false if referent.class == String
|
36
|
+
referent.ancestors.include? Persisting
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
data/lib/rhubarb/rhubarb.rb
CHANGED
@@ -13,533 +13,10 @@
|
|
13
13
|
|
14
14
|
require 'rubygems'
|
15
15
|
require 'set'
|
16
|
-
require 'time'
|
17
16
|
require 'sqlite3'
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
(tm.tv_sec * 1000000) + tm.tv_usec
|
25
|
-
end
|
26
|
-
end
|
27
|
-
|
28
|
-
|
29
|
-
module Persistence
|
30
|
-
class DbCollection < Hash
|
31
|
-
alias orig_set []=
|
32
|
-
|
33
|
-
def []=(k,v)
|
34
|
-
v.results_as_hash = true if v
|
35
|
-
v.type_translation = true if v
|
36
|
-
orig_set(k,v)
|
37
|
-
end
|
38
|
-
end
|
39
|
-
|
40
|
-
@@dbs = DbCollection.new
|
41
|
-
|
42
|
-
def self.open(filename, which=:default)
|
43
|
-
dbs[which] = SQLite3::Database.new(filename)
|
44
|
-
end
|
45
|
-
|
46
|
-
def self.close(which=:default)
|
47
|
-
if dbs[which]
|
48
|
-
dbs[which].close
|
49
|
-
dbs.delete(which)
|
50
|
-
end
|
51
|
-
end
|
52
|
-
|
53
|
-
def self.db
|
54
|
-
dbs[:default]
|
55
|
-
end
|
56
|
-
|
57
|
-
def self.db=(d)
|
58
|
-
dbs[:default] = d
|
59
|
-
end
|
60
|
-
|
61
|
-
def self.dbs
|
62
|
-
@@dbs
|
63
|
-
end
|
64
|
-
end
|
65
|
-
|
66
|
-
class Column
|
67
|
-
attr_reader :name
|
68
|
-
|
69
|
-
def initialize(name, kind, quals)
|
70
|
-
@name, @kind = name, kind
|
71
|
-
@quals = quals.map {|x| x.to_s.gsub("_", " ") if x.class == Symbol}
|
72
|
-
@quals.map
|
73
|
-
end
|
74
|
-
|
75
|
-
def to_s
|
76
|
-
qualifiers = @quals.join(" ")
|
77
|
-
if qualifiers == ""
|
78
|
-
"#@name #@kind"
|
79
|
-
else
|
80
|
-
"#@name #@kind #{qualifiers}"
|
81
|
-
end
|
82
|
-
end
|
83
|
-
end
|
84
|
-
|
85
|
-
class Reference
|
86
|
-
attr_reader :referent, :column, :options
|
87
|
-
|
88
|
-
# Creates a new Reference object, modeling a foreign-key relationship to another table. +klass+ is a class that includes Persisting; +options+ is a hash of options, which include
|
89
|
-
# +:column => + _name_:: specifies the name of the column to reference in +klass+ (defaults to row id)
|
90
|
-
# +:on_delete => :cascade+:: specifies that deleting the referenced row in +klass+ will delete all rows referencing that row through this reference
|
91
|
-
def initialize(klass, options={})
|
92
|
-
@referent = klass
|
93
|
-
@options = options
|
94
|
-
@options[:column] ||= "row_id"
|
95
|
-
@column = options[:column]
|
96
|
-
end
|
97
|
-
|
98
|
-
def to_s
|
99
|
-
trigger = ""
|
100
|
-
trigger = " on delete cascade" if options[:on_delete] == :cascade
|
101
|
-
"references #{@referent}(#{@column})#{trigger}"
|
102
|
-
end
|
103
|
-
|
104
|
-
def managed_ref?
|
105
|
-
# XXX?
|
106
|
-
return false if referent.class == String
|
107
|
-
referent.ancestors.include? Persisting
|
108
|
-
end
|
109
|
-
end
|
110
|
-
|
111
|
-
|
112
|
-
# Methods mixed in to the class object of a persisting class
|
113
|
-
module PersistingClassMixins
|
114
|
-
# Returns the name of the database table modeled by this class.
|
115
|
-
# Defaults to the name of the class (sans module names)
|
116
|
-
def table_name
|
117
|
-
@table_name ||= self.name.split("::").pop.downcase
|
118
|
-
end
|
119
|
-
|
120
|
-
# Enables setting the table name to a custom name
|
121
|
-
def declare_table_name(nm)
|
122
|
-
@table_name = nm
|
123
|
-
end
|
124
|
-
|
125
|
-
# Models a foreign-key relationship. +options+ is a hash of options, which include
|
126
|
-
# +:column => + _name_:: specifies the name of the column to reference in +klass+ (defaults to row id)
|
127
|
-
# +:on_delete => :cascade+:: specifies that deleting the referenced row in +klass+ will delete all rows referencing that row through this reference
|
128
|
-
def references(table, options={})
|
129
|
-
Reference.new(table, options)
|
130
|
-
end
|
131
|
-
|
132
|
-
# Models a CHECK constraint.
|
133
|
-
def check(condition)
|
134
|
-
"check (#{condition})"
|
135
|
-
end
|
136
|
-
|
137
|
-
# Returns an object corresponding to the row with the given ID, or +nil+ if no such row exists.
|
138
|
-
def find(id)
|
139
|
-
tup = self.find_tuple(id)
|
140
|
-
return self.new(tup) if tup
|
141
|
-
nil
|
142
|
-
end
|
143
|
-
|
144
|
-
alias find_by_id find
|
145
|
-
|
146
|
-
def find_by(arg_hash)
|
147
|
-
arg_hash = arg_hash.dup
|
148
|
-
valid_cols = self.colnames.intersection arg_hash.keys
|
149
|
-
select_criteria = valid_cols.map {|col| "#{col.to_s} = #{col.inspect}"}.join(" AND ")
|
150
|
-
arg_hash.each {|k,v| arg_hash[k] = v.row_id if v.respond_to? :row_id}
|
151
|
-
|
152
|
-
self.db.execute("select * from #{table_name} where #{select_criteria} order by row_id", arg_hash).map {|tup| self.new(tup) }
|
153
|
-
end
|
154
|
-
|
155
|
-
# args contains the following keys
|
156
|
-
# * :group_by maps to a list of columns to group by (mandatory)
|
157
|
-
# * :select_by maps to a hash mapping from column symbols to values (optional)
|
158
|
-
# * :version maps to some version to be considered "current" for the purposes of this query; that is, all rows later than the "current" version will be disregarded (optional, defaults to latest version)
|
159
|
-
def find_freshest(args)
|
160
|
-
args = args.dup
|
161
|
-
|
162
|
-
args[:version] ||= SQLBUtil::timestamp
|
163
|
-
args[:select_by] ||= {}
|
164
|
-
|
165
|
-
query_params = {}
|
166
|
-
query_params[:version] = args[:version]
|
167
|
-
|
168
|
-
select_clauses = ["created <= :version"]
|
169
|
-
|
170
|
-
valid_cols = self.colnames.intersection args[:select_by].keys
|
171
|
-
|
172
|
-
valid_cols.map do |col|
|
173
|
-
select_clauses << "#{col.to_s} = #{col.inspect}"
|
174
|
-
val = args[:select_by][col]
|
175
|
-
val = val.row_id if val.respond_to? :row_id
|
176
|
-
query_params[col] = val
|
177
|
-
end
|
178
|
-
|
179
|
-
group_by_clause = "GROUP BY " + args[:group_by].join(", ")
|
180
|
-
where_clause = "WHERE " + select_clauses.join(" AND ")
|
181
|
-
projection = self.colnames - [:created]
|
182
|
-
join_clause = projection.map do |column|
|
183
|
-
"__fresh.#{column} = __freshest.#{column}"
|
184
|
-
end
|
185
|
-
|
186
|
-
projection << "MAX(created) AS __current_version"
|
187
|
-
join_clause << "__fresh.__current_version = __freshest.created"
|
188
|
-
|
189
|
-
query = "
|
190
|
-
SELECT __freshest.* FROM (
|
191
|
-
SELECT #{projection.to_a.join(', ')} FROM (
|
192
|
-
SELECT * from #{table_name} #{where_clause}
|
193
|
-
) #{group_by_clause}
|
194
|
-
) as __fresh INNER JOIN #{table_name} as __freshest ON
|
195
|
-
#{join_clause.join(' AND ')}
|
196
|
-
ORDER BY row_id
|
197
|
-
"
|
198
|
-
|
199
|
-
self.db.execute(query, query_params).map {|tup| self.new(tup) }
|
200
|
-
end
|
201
|
-
|
202
|
-
# Does what it says on the tin. Since this will allocate an object for each row, it isn't recomended for huge tables.
|
203
|
-
def find_all
|
204
|
-
self.db.execute("SELECT * from #{table_name}").map {|tup| self.new(tup)}
|
205
|
-
end
|
206
|
-
|
207
|
-
def delete_all
|
208
|
-
self.db.execute("DELETE from #{table_name}")
|
209
|
-
end
|
210
|
-
|
211
|
-
# Declares a query method named +name+ and adds it to this class. The query method returns a list of objects corresponding to the rows returned by executing "+SELECT * FROM+ _table_ +WHERE+ _query_" on the database.
|
212
|
-
def declare_query(name, query)
|
213
|
-
klass = (class << self; self end)
|
214
|
-
klass.class_eval do
|
215
|
-
define_method name.to_s do |*args|
|
216
|
-
# handle reference parameters
|
217
|
-
args = args.map {|x| (x.row_id if x.class.ancestors.include? Persisting) or x}
|
218
|
-
|
219
|
-
res = self.db.execute("select * from #{table_name} where #{query}", args)
|
220
|
-
res.map {|row| self.new(row)}
|
221
|
-
end
|
222
|
-
end
|
223
|
-
end
|
224
|
-
|
225
|
-
# Declares a custom query method named +name+, and adds it to this class. The custom query method returns a list of objects corresponding to the rows returned by executing +query+ on the database. +query+ should select all fields (with +SELECT *+). If +query+ includes the string +\_\_TABLE\_\_+, it will be expanded to the table name. Typically, you will want to use +declare\_query+ instead; this method is most useful for self-joins.
|
226
|
-
def declare_custom_query(name, query)
|
227
|
-
klass = (class << self; self end)
|
228
|
-
klass.class_eval do
|
229
|
-
define_method name.to_s do |*args|
|
230
|
-
# handle reference parameters
|
231
|
-
args = args.map {|x| (x.row_id if x.class.ancestors.include? Persisting) or x}
|
232
|
-
|
233
|
-
res = self.db.execute(query.gsub("__TABLE__", "#{self.table_name}"), args)
|
234
|
-
# XXX: should freshen each row?
|
235
|
-
res.map {|row| self.new(row) }
|
236
|
-
end
|
237
|
-
end
|
238
|
-
end
|
239
|
-
|
240
|
-
def declare_index_on(*fields)
|
241
|
-
@creation_callbacks << Proc.new do
|
242
|
-
idx_name = "idx_#{self.table_name}__#{fields.join('__')}__#{@creation_callbacks.size}"
|
243
|
-
creation_cmd = "create index #{idx_name} on #{self.table_name} (#{fields.join(', ')})"
|
244
|
-
self.db.execute(creation_cmd)
|
245
|
-
end if fields.size > 0
|
246
|
-
end
|
247
|
-
|
248
|
-
# Adds a column named +cname+ to this table declaration, and adds the following methods to the class:
|
249
|
-
# * accessors for +cname+, called +cname+ and +cname=+
|
250
|
-
# * +find\_by\_cname+ and +find\_first\_by\_cname+ methods, which return a list of rows and the first row that have the given value for +cname+, respectively
|
251
|
-
# If the column references a column in another table (given via a +references(...)+ argument to +quals+), then add triggers to the database to ensure referential integrity and cascade-on-delete (if specified)
|
252
|
-
def declare_column(cname, kind, *quals)
|
253
|
-
ensure_accessors
|
254
|
-
|
255
|
-
find_method_name = "find_by_#{cname}".to_sym
|
256
|
-
find_first_method_name = "find_first_by_#{cname}".to_sym
|
257
|
-
|
258
|
-
get_method_name = "#{cname}".to_sym
|
259
|
-
set_method_name = "#{cname}=".to_sym
|
260
|
-
|
261
|
-
# does this column reference another table?
|
262
|
-
rf = quals.find {|q| q.class == Reference}
|
263
|
-
if rf
|
264
|
-
self.refs[cname] = rf
|
265
|
-
end
|
266
|
-
|
267
|
-
# add a find for this column (a class method)
|
268
|
-
klass = (class << self; self end)
|
269
|
-
klass.class_eval do
|
270
|
-
define_method find_method_name do |arg|
|
271
|
-
res = self.db.execute("select * from #{table_name} where #{cname} = ?", arg)
|
272
|
-
res.map {|row| self.new(row)}
|
273
|
-
end
|
274
|
-
|
275
|
-
define_method find_first_method_name do |arg|
|
276
|
-
res = self.db.execute("select * from #{table_name} where #{cname} = ?", arg)
|
277
|
-
return self.new(res[0]) if res.size > 0
|
278
|
-
nil
|
279
|
-
end
|
280
|
-
end
|
281
|
-
|
282
|
-
self.colnames.merge([cname])
|
283
|
-
self.columns << Column.new(cname, kind, quals)
|
284
|
-
|
285
|
-
# add accessors
|
286
|
-
define_method get_method_name do
|
287
|
-
freshen
|
288
|
-
return @tuple["#{cname}"] if @tuple
|
289
|
-
nil
|
290
|
-
end
|
291
|
-
|
292
|
-
if not rf
|
293
|
-
define_method set_method_name do |arg|
|
294
|
-
@tuple["#{cname}"] = arg
|
295
|
-
update cname, arg
|
296
|
-
end
|
297
|
-
else
|
298
|
-
# this column references another table; create a set
|
299
|
-
# method that can handle either row objects or row IDs
|
300
|
-
define_method set_method_name do |arg|
|
301
|
-
freshen
|
302
|
-
|
303
|
-
arg_id = nil
|
304
|
-
|
305
|
-
if arg.class == Fixnum
|
306
|
-
arg_id = arg
|
307
|
-
arg = rf.referent.find arg_id
|
308
|
-
else
|
309
|
-
arg_id = arg.row_id
|
310
|
-
end
|
311
|
-
@tuple["#{cname}"] = arg
|
312
|
-
|
313
|
-
update cname, arg_id
|
314
|
-
end
|
315
|
-
|
316
|
-
# Finally, add appropriate triggers to ensure referential integrity.
|
317
|
-
# If rf has an on_delete trigger, also add the necessary
|
318
|
-
# triggers to cascade deletes.
|
319
|
-
# Note that we do not support update triggers, since the API does
|
320
|
-
# not expose the capacity to change row IDs.
|
321
|
-
|
322
|
-
self.creation_callbacks << Proc.new do
|
323
|
-
@ccount ||= 0
|
324
|
-
|
325
|
-
insert_trigger_name = "ri_insert_#{self.table_name}_#{@ccount}_#{rf.referent.table_name}"
|
326
|
-
delete_trigger_name = "ri_delete_#{self.table_name}_#{@ccount}_#{rf.referent.table_name}"
|
327
|
-
|
328
|
-
self.db.execute_batch("CREATE TRIGGER #{insert_trigger_name} BEFORE INSERT ON \"#{self.table_name}\" WHEN new.\"#{cname}\" IS NOT NULL AND NOT EXISTS (SELECT 1 FROM \"#{rf.referent.table_name}\" WHERE new.\"#{cname}\" == \"#{rf.column}\") BEGIN SELECT RAISE(ABORT, 'constraint #{insert_trigger_name} (#{rf.referent.table_name} missing foreign key row) failed'); END;")
|
329
|
-
|
330
|
-
self.db.execute_batch("CREATE TRIGGER #{delete_trigger_name} BEFORE DELETE ON \"#{rf.referent.table_name}\" WHEN EXISTS (SELECT 1 FROM \"#{self.table_name}\" WHERE old.\"#{rf.column}\" == \"#{cname}\") BEGIN DELETE FROM \"#{self.table_name}\" WHERE \"#{cname}\" = old.\"#{rf.column}\"; END;") if rf.options[:on_delete] == :cascade
|
331
|
-
|
332
|
-
@ccount = @ccount + 1
|
333
|
-
end
|
334
|
-
end
|
335
|
-
end
|
336
|
-
|
337
|
-
# Declares a constraint. Only check constraints are supported; see
|
338
|
-
# the check method.
|
339
|
-
def declare_constraint(cname, kind, *details)
|
340
|
-
ensure_accessors
|
341
|
-
info = details.join(" ")
|
342
|
-
@constraints << "constraint #{cname} #{kind} #{info}"
|
343
|
-
end
|
344
|
-
|
345
|
-
# Creates a new row in the table with the supplied column values.
|
346
|
-
# May throw a SQLite3::SQLException.
|
347
|
-
def create(*args)
|
348
|
-
new_row = args[0]
|
349
|
-
new_row[:created] = new_row[:updated] = SQLBUtil::timestamp
|
350
|
-
|
351
|
-
cols = colnames.intersection new_row.keys
|
352
|
-
colspec = (cols.map {|col| col.to_s}).join(", ")
|
353
|
-
valspec = (cols.map {|col| col.inspect}).join(", ")
|
354
|
-
res = nil
|
355
|
-
|
356
|
-
# resolve any references in the args
|
357
|
-
new_row.each do |k,v|
|
358
|
-
new_row[k] = v.row_id if v.class.ancestors.include? Persisting
|
359
|
-
end
|
360
|
-
|
361
|
-
self.db.transaction do |db|
|
362
|
-
stmt = "insert into #{table_name} (#{colspec}) values (#{valspec})"
|
363
|
-
# p stmt
|
364
|
-
db.execute(stmt, new_row)
|
365
|
-
res = find(db.last_insert_row_id)
|
366
|
-
end
|
367
|
-
res
|
368
|
-
end
|
369
|
-
|
370
|
-
# Returns a string consisting of the DDL statement to create a table
|
371
|
-
# corresponding to this class.
|
372
|
-
def table_decl
|
373
|
-
cols = columns.join(", ")
|
374
|
-
consts = constraints.join(", ")
|
375
|
-
if consts.size > 0
|
376
|
-
"create table #{table_name} (#{cols}, #{consts});"
|
377
|
-
else
|
378
|
-
"create table #{table_name} (#{cols});"
|
379
|
-
end
|
380
|
-
end
|
381
|
-
|
382
|
-
# Creates a table in the database corresponding to this class.
|
383
|
-
def create_table(dbkey=:default)
|
384
|
-
self.db ||= Persistence::dbs[dbkey]
|
385
|
-
self.db.execute(table_decl)
|
386
|
-
@creation_callbacks.each {|func| func.call}
|
387
|
-
end
|
388
|
-
|
389
|
-
def db
|
390
|
-
@db || Persistence::db
|
391
|
-
end
|
392
|
-
|
393
|
-
def db=(d)
|
394
|
-
@db = d
|
395
|
-
end
|
396
|
-
|
397
|
-
# Ensure that all the necessary accessors on our class instance are defined
|
398
|
-
# and that all metaclass fields have the appropriate values
|
399
|
-
def ensure_accessors
|
400
|
-
# Define singleton accessors
|
401
|
-
if not self.respond_to? :columns
|
402
|
-
class << self
|
403
|
-
# Arrays of columns, column names, and column constraints.
|
404
|
-
# Note that colnames does not contain id, created, or updated.
|
405
|
-
# The API purposefully does not expose the ability to create a
|
406
|
-
# row with a given id, and created and updated values are
|
407
|
-
# maintained automatically by the API.
|
408
|
-
attr_accessor :columns, :colnames, :constraints, :dirtied, :refs, :creation_callbacks
|
409
|
-
end
|
410
|
-
end
|
411
|
-
|
412
|
-
# Ensure singleton fields are initialized
|
413
|
-
self.columns ||= [Column.new(:row_id, :integer, [:primary_key]), Column.new(:created, :integer, []), Column.new(:updated, :integer, [])]
|
414
|
-
self.colnames ||= Set.new [:created, :updated]
|
415
|
-
self.constraints ||= []
|
416
|
-
self.dirtied ||= {}
|
417
|
-
self.refs ||= {}
|
418
|
-
self.creation_callbacks ||= []
|
419
|
-
end
|
420
|
-
|
421
|
-
# Returns the number of rows in the table backing this class
|
422
|
-
def count
|
423
|
-
result = self.db.execute("select count(row_id) from #{table_name}")[0]
|
424
|
-
result[0].to_i
|
425
|
-
end
|
426
|
-
|
427
|
-
def find_tuple(id)
|
428
|
-
res = self.db.execute("select * from #{table_name} where row_id = ?", id)
|
429
|
-
if res.size == 0
|
430
|
-
nil
|
431
|
-
else
|
432
|
-
res[0]
|
433
|
-
end
|
434
|
-
end
|
435
|
-
end
|
436
|
-
|
437
|
-
module Persisting
|
438
|
-
def self.included(other)
|
439
|
-
class << other
|
440
|
-
include PersistingClassMixins
|
441
|
-
end
|
442
|
-
|
443
|
-
other.class_eval do
|
444
|
-
attr_reader :row_id
|
445
|
-
attr_reader :created
|
446
|
-
attr_reader :updated
|
447
|
-
end
|
448
|
-
end
|
449
|
-
|
450
|
-
def db
|
451
|
-
self.class.db
|
452
|
-
end
|
453
|
-
|
454
|
-
# Returns true if the row backing this object has been deleted from the database
|
455
|
-
def deleted?
|
456
|
-
freshen
|
457
|
-
not @tuple
|
458
|
-
end
|
459
|
-
|
460
|
-
# Initializes a new instance backed by a tuple of values. Do not call this directly.
|
461
|
-
# Create new instances with the create or find methods.
|
462
|
-
def initialize(tup)
|
463
|
-
@backed = true
|
464
|
-
@tuple = tup
|
465
|
-
mark_fresh
|
466
|
-
@row_id = @tuple["row_id"]
|
467
|
-
@created = @tuple["created"]
|
468
|
-
@updated = @tuple["updated"]
|
469
|
-
resolve_referents
|
470
|
-
self.class.dirtied[@row_id] ||= @expired_after
|
471
|
-
self
|
472
|
-
end
|
473
|
-
|
474
|
-
# Deletes the row corresponding to this object from the database;
|
475
|
-
# invalidates =self= and any other objects backed by this row
|
476
|
-
def delete
|
477
|
-
self.db.execute("delete from #{self.class.table_name} where row_id = ?", @row_id)
|
478
|
-
mark_dirty
|
479
|
-
@tuple = nil
|
480
|
-
@row_id = nil
|
481
|
-
end
|
482
|
-
|
483
|
-
## Begin private methods
|
484
|
-
|
485
|
-
private
|
486
|
-
|
487
|
-
# Fetches updated attribute values from the database if necessary
|
488
|
-
def freshen
|
489
|
-
if needs_refresh?
|
490
|
-
@tuple = self.class.find_tuple(@row_id)
|
491
|
-
if @tuple
|
492
|
-
@updated = @tuple["updated"]
|
493
|
-
else
|
494
|
-
@row_id = @updated = @created = nil
|
495
|
-
end
|
496
|
-
mark_fresh
|
497
|
-
resolve_referents
|
498
|
-
end
|
499
|
-
end
|
500
|
-
|
501
|
-
# True if the underlying row in the database is inconsistent with the state
|
502
|
-
# of this object, whether because the row has changed, or because this object has no row id
|
503
|
-
def needs_refresh?
|
504
|
-
if not @row_id
|
505
|
-
@tuple != nil
|
506
|
-
else
|
507
|
-
@expired_after < self.class.dirtied[@row_id]
|
508
|
-
end
|
509
|
-
end
|
510
|
-
|
511
|
-
# Mark this row as dirty so that any other objects backed by this row will
|
512
|
-
# update from the database before their attributes are inspected
|
513
|
-
def mark_dirty
|
514
|
-
self.class.dirtied[@row_id] = SQLBUtil::timestamp
|
515
|
-
end
|
516
|
-
|
517
|
-
# Mark this row as consistent with the underlying database as of now
|
518
|
-
def mark_fresh
|
519
|
-
@expired_after = SQLBUtil::timestamp
|
520
|
-
end
|
521
|
-
|
522
|
-
# Helper method to update the row in the database when one of our fields changes
|
523
|
-
def update(attr_name, value)
|
524
|
-
mark_dirty
|
525
|
-
self.db.execute("update #{self.class.table_name} set #{attr_name} = ?, updated = ? where row_id = ?", value, SQLBUtil::timestamp, @row_id)
|
526
|
-
end
|
527
|
-
|
528
|
-
# Resolve any fields that reference other tables, replacing row ids with referred objects
|
529
|
-
def resolve_referents
|
530
|
-
refs = self.class.refs
|
531
|
-
|
532
|
-
refs.each do |c,r|
|
533
|
-
c = c.to_s
|
534
|
-
if r.referent == self.class and @tuple[c] == row_id
|
535
|
-
@tuple[c] = self
|
536
|
-
else
|
537
|
-
row = r.referent.find @tuple[c]
|
538
|
-
@tuple[c] = row if row
|
539
|
-
end
|
540
|
-
end
|
541
|
-
end
|
542
|
-
|
543
|
-
end
|
544
|
-
|
545
|
-
end
|
17
|
+
require 'rhubarb/util'
|
18
|
+
require 'rhubarb/persistence'
|
19
|
+
require 'rhubarb/column'
|
20
|
+
require 'rhubarb/reference'
|
21
|
+
require 'rhubarb/classmixins'
|
22
|
+
require 'rhubarb/persisting'
|
data/lib/rhubarb/util.rb
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# Timestamp function for Rhubarb.
|
2
|
+
#
|
3
|
+
# Copyright (c) 2009--2010 Red Hat, Inc.
|
4
|
+
#
|
5
|
+
# Author: William Benton (willb@redhat.com)
|
6
|
+
#
|
7
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
8
|
+
# you may not use this file except in compliance with the License.
|
9
|
+
# You may obtain a copy of the License at
|
10
|
+
#
|
11
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
12
|
+
|
13
|
+
require 'time'
|
14
|
+
|
15
|
+
module Rhubarb
|
16
|
+
module Util
|
17
|
+
# A higher-resolution timestamp
|
18
|
+
def self.timestamp(tm=nil)
|
19
|
+
tm ||= Time.now.utc
|
20
|
+
(tm.tv_sec * 1000000) + tm.tv_usec
|
21
|
+
end
|
22
|
+
|
23
|
+
# Identity for objects that may be used as foreign keys
|
24
|
+
def self.rhubarb_fk_identity(object)
|
25
|
+
(object.row_id if object.class.ancestors.include? Persisting) || object
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
data/ruby-rhubarb.spec
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
%{!?ruby_sitelib: %global ruby_sitelib %(ruby -rrbconfig -e 'puts Config::CONFIG["sitelibdir"] ')}
|
2
|
+
%define rel 0.3
|
3
|
+
|
4
|
+
Summary: Simple versioned object-graph persistence layer
|
5
|
+
Name: ruby-rhubarb
|
6
|
+
Version: 0.2.0
|
7
|
+
Release: %{rel}%{?dist}
|
8
|
+
Group: Applications/System
|
9
|
+
License: ASL 2.0
|
10
|
+
URL: http://git.fedorahosted.org/git/grid/rhubarb.git
|
11
|
+
Source0: %{name}-%{version}-%{rel}.tar.gz
|
12
|
+
BuildRoot: %(mktemp -ud %{_tmppath}/%{name}-%{version}-%{release}-XXXXXX)
|
13
|
+
Requires: ruby-sqlite3
|
14
|
+
Requires: ruby
|
15
|
+
Requires: ruby(abi) = 1.8
|
16
|
+
BuildRequires: ruby
|
17
|
+
BuildArch: noarch
|
18
|
+
|
19
|
+
%description
|
20
|
+
A simple versioned object-graph persistence layer that stores
|
21
|
+
instances of specially-declared Ruby classes in a SQLite3 database
|
22
|
+
|
23
|
+
%prep
|
24
|
+
%setup -q
|
25
|
+
|
26
|
+
%build
|
27
|
+
|
28
|
+
%install
|
29
|
+
rm -rf %{buildroot}
|
30
|
+
mkdir -p %{buildroot}/%{ruby_sitelib}/rhubarb
|
31
|
+
mkdir -p %{buildroot}/%{ruby_sitelib}/rhubarb/mixins
|
32
|
+
cp -f lib/rhubarb/mixins/*.rb %{buildroot}/%{ruby_sitelib}/rhubarb/mixins
|
33
|
+
cp -f lib/rhubarb/*.rb %{buildroot}/%{ruby_sitelib}/rhubarb
|
34
|
+
|
35
|
+
%clean
|
36
|
+
rm -rf %{buildroot}
|
37
|
+
|
38
|
+
%files
|
39
|
+
%defattr(-, root, root, -)
|
40
|
+
%doc LICENSE README.rdoc CHANGES TODO VERSION
|
41
|
+
%{ruby_sitelib}/rhubarb/rhubarb.rb
|
42
|
+
%{ruby_sitelib}/rhubarb/classmixins.rb
|
43
|
+
%{ruby_sitelib}/rhubarb/mixins/freshness.rb
|
44
|
+
%{ruby_sitelib}/rhubarb/column.rb
|
45
|
+
%{ruby_sitelib}/rhubarb/reference.rb
|
46
|
+
%{ruby_sitelib}/rhubarb/util.rb
|
47
|
+
%{ruby_sitelib}/rhubarb/persisting.rb
|
48
|
+
%{ruby_sitelib}/rhubarb/persistence.rb
|
49
|
+
|
50
|
+
%changelog
|
51
|
+
* Fri Feb 5 2010 <rrat@redhat> - 0.2.0-0.3
|
52
|
+
- Explicitly list files
|
53
|
+
- Added ruby(abi) and dependency
|
54
|
+
- Added ruby build dependency
|
55
|
+
|
56
|
+
* Thu Feb 4 2010 <willb@redhat.com> - 0.2.0-0.2
|
57
|
+
- Post-0.2 cleanups
|
58
|
+
|
59
|
+
* Tue Feb 2 2010 <rrati@redhat> - 0.2.0-0.1
|
60
|
+
- Initial package
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rhubarb
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- William Benton
|
@@ -9,7 +9,7 @@ autorequire:
|
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
11
|
|
12
|
-
date: 2010-02-
|
12
|
+
date: 2010-02-15 00:00:00 -06:00
|
13
13
|
default_executable:
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|
@@ -51,7 +51,15 @@ files:
|
|
51
51
|
- Rakefile
|
52
52
|
- TODO
|
53
53
|
- VERSION
|
54
|
+
- lib/rhubarb/classmixins.rb
|
55
|
+
- lib/rhubarb/column.rb
|
56
|
+
- lib/rhubarb/mixins/freshness.rb
|
57
|
+
- lib/rhubarb/persistence.rb
|
58
|
+
- lib/rhubarb/persisting.rb
|
59
|
+
- lib/rhubarb/reference.rb
|
54
60
|
- lib/rhubarb/rhubarb.rb
|
61
|
+
- lib/rhubarb/util.rb
|
62
|
+
- ruby-rhubarb.spec
|
55
63
|
- test/helper.rb
|
56
64
|
- test/test_rhubarb.rb
|
57
65
|
has_rdoc: true
|