ruflet_record 0.0.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +173 -0
- data/lib/ruflet_record/adapters/sqlite_adapter.rb +210 -0
- data/lib/ruflet_record/base.rb +419 -0
- data/lib/ruflet_record/column.rb +68 -0
- data/lib/ruflet_record/errors.rb +64 -0
- data/lib/ruflet_record/inflector.rb +42 -0
- data/lib/ruflet_record/relation.rb +292 -0
- data/lib/ruflet_record/schema.rb +211 -0
- data/lib/ruflet_record/sql.rb +175 -0
- data/lib/ruflet_record/version.rb +5 -0
- data/lib/ruflet_record.rb +36 -0
- metadata +67 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RufletRecord
|
|
4
|
+
class WhereChain
|
|
5
|
+
def initialize(relation)
|
|
6
|
+
@relation = relation
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def not(conditions, *binds)
|
|
10
|
+
fragment = SQL.predicate(@relation.table_name, conditions, binds)
|
|
11
|
+
@relation.add_where(SQL.negate(fragment))
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class Relation
|
|
16
|
+
include Enumerable
|
|
17
|
+
|
|
18
|
+
attr_reader :model, :table_name
|
|
19
|
+
|
|
20
|
+
def initialize(model, values = nil)
|
|
21
|
+
@model = model
|
|
22
|
+
@table_name = model.table_name
|
|
23
|
+
@values = values || {
|
|
24
|
+
where: [], order: [], select: [], joins: [], limit: nil,
|
|
25
|
+
offset: nil, distinct: false
|
|
26
|
+
}
|
|
27
|
+
@loaded = false
|
|
28
|
+
@records = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def where(conditions = nil, *binds)
|
|
32
|
+
return WhereChain.new(self) if conditions.nil?
|
|
33
|
+
add_where(SQL.predicate(@table_name, conditions, binds))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def add_where(fragment)
|
|
37
|
+
spawn_with(:where, @values[:where] + [fragment])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def order(*values)
|
|
41
|
+
additions = values.flatten
|
|
42
|
+
SQL.order(@table_name, additions)
|
|
43
|
+
spawn_with(:order, @values[:order] + additions)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def reorder(*values)
|
|
47
|
+
replacements = values.flatten
|
|
48
|
+
SQL.order(@table_name, replacements)
|
|
49
|
+
spawn_with(:order, replacements)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def limit(value)
|
|
53
|
+
number = value.nil? ? nil : value.to_i
|
|
54
|
+
raise ArgumentError, "limit must not be negative" if number && number < 0
|
|
55
|
+
spawn_with(:limit, number)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def offset(value)
|
|
59
|
+
number = value.nil? ? nil : value.to_i
|
|
60
|
+
raise ArgumentError, "offset must not be negative" if number && number < 0
|
|
61
|
+
spawn_with(:offset, number)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def select(*columns)
|
|
65
|
+
spawn_with(:select, columns.flatten)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def distinct(value = true)
|
|
69
|
+
spawn_with(:distinct, !!value)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def joins(sql)
|
|
73
|
+
value = sql.is_a?(SQL::Literal) ? sql.sql : sql.to_s
|
|
74
|
+
raise ArgumentError, "joins requires an explicit JOIN expression" unless value =~ /\A\s*(INNER|LEFT|RIGHT|CROSS)?\s*JOIN\s+/i
|
|
75
|
+
spawn_with(:joins, @values[:joins] + [value])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def merge(other)
|
|
79
|
+
raise ArgumentError, "relations must have the same model" unless other.model == @model
|
|
80
|
+
merged = duplicate_values
|
|
81
|
+
other_values = other.send(:values)
|
|
82
|
+
merged[:where] += other_values[:where]
|
|
83
|
+
merged[:order] += other_values[:order]
|
|
84
|
+
merged[:joins] += other_values[:joins]
|
|
85
|
+
merged[:select] = other_values[:select] unless other_values[:select].empty?
|
|
86
|
+
merged[:limit] = other_values[:limit] unless other_values[:limit].nil?
|
|
87
|
+
merged[:offset] = other_values[:offset] unless other_values[:offset].nil?
|
|
88
|
+
merged[:distinct] ||= other_values[:distinct]
|
|
89
|
+
self.class.new(@model, merged)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def each(&block)
|
|
93
|
+
return to_enum(:each) unless block
|
|
94
|
+
to_a.each(&block)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def to_a
|
|
98
|
+
load unless @loaded
|
|
99
|
+
@records.dup
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def load
|
|
103
|
+
sql, binds = to_sql_and_binds
|
|
104
|
+
@records = @model.connection.select_all(sql, binds).map { |row| @model.instantiate(row) }
|
|
105
|
+
@loaded = true
|
|
106
|
+
self
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def loaded?
|
|
110
|
+
@loaded
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def reload
|
|
114
|
+
@loaded = false
|
|
115
|
+
@records = nil
|
|
116
|
+
load
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def first(count = nil)
|
|
120
|
+
return limit(count).to_a if count
|
|
121
|
+
limit(1).to_a.first
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def last(count = nil)
|
|
125
|
+
primary = @model.primary_key
|
|
126
|
+
relation = reorder(primary => :desc)
|
|
127
|
+
records = count ? relation.limit(count).to_a.reverse : relation.limit(1).to_a.first
|
|
128
|
+
records
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def find(id)
|
|
132
|
+
record = where(@model.primary_key => id).first
|
|
133
|
+
raise RecordNotFound, "Couldn't find #{@model.name} with '#{@model.primary_key}'=#{id}" unless record
|
|
134
|
+
record
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def find_by(conditions)
|
|
138
|
+
where(conditions).first
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def find_by!(conditions)
|
|
142
|
+
record = find_by(conditions)
|
|
143
|
+
raise RecordNotFound, "Couldn't find #{@model.name}" unless record
|
|
144
|
+
record
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def count(column = nil)
|
|
148
|
+
aggregate("COUNT", column || SQL::Literal.new("*"), integer: true)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def sum(column)
|
|
152
|
+
aggregate("SUM", column) || 0
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def average(column)
|
|
156
|
+
aggregate("AVG", column)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def minimum(column)
|
|
160
|
+
aggregate("MIN", column)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def maximum(column)
|
|
164
|
+
aggregate("MAX", column)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def exists?(conditions = nil)
|
|
168
|
+
relation = conditions.nil? ? self : where(conditions)
|
|
169
|
+
!relation.select(SQL::Literal.new("1")).limit(1).send(:pluck_value).nil?
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def pluck(*columns)
|
|
173
|
+
names = columns.flatten
|
|
174
|
+
rows = select(*names).send(:raw_rows)
|
|
175
|
+
return rows.map { |row| row[names.first.to_s] } if names.length == 1
|
|
176
|
+
rows.map { |row| names.map { |name| row[name.to_s] } }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def pick(*columns)
|
|
180
|
+
values = limit(1).pluck(*columns)
|
|
181
|
+
values.first
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def ids
|
|
185
|
+
pluck(@model.primary_key)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def create(attributes = {})
|
|
189
|
+
record = @model.new(scope_for_create.merge(attributes))
|
|
190
|
+
record.save
|
|
191
|
+
record
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def create!(attributes = {})
|
|
195
|
+
record = @model.new(scope_for_create.merge(attributes))
|
|
196
|
+
record.save!
|
|
197
|
+
record
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def update_all(attributes)
|
|
201
|
+
raise ArgumentError, "attributes must not be empty" if attributes.empty?
|
|
202
|
+
sql, binds = SQL.update(@table_name, stringify_keys(attributes), @values[:where])
|
|
203
|
+
@model.connection.update(sql, binds)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def delete_all
|
|
207
|
+
sql, binds = SQL.delete(@table_name, @values[:where])
|
|
208
|
+
@model.connection.delete(sql, binds)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def destroy_all
|
|
212
|
+
to_a.each(&:destroy)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def find_each(batch_size: 1000)
|
|
216
|
+
return to_enum(:find_each, batch_size: batch_size) unless block_given?
|
|
217
|
+
cursor = nil
|
|
218
|
+
loop do
|
|
219
|
+
relation = reorder(@model.primary_key => :asc).limit(batch_size)
|
|
220
|
+
relation = relation.where("#{SQL.column(@table_name, @model.primary_key)} > ?", cursor) if cursor
|
|
221
|
+
batch = relation.to_a
|
|
222
|
+
break if batch.empty?
|
|
223
|
+
batch.each { |record| yield record }
|
|
224
|
+
cursor = batch.last.public_send(@model.primary_key)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def to_sql
|
|
229
|
+
to_sql_and_binds.first
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def bound_attributes
|
|
233
|
+
to_sql_and_binds.last
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
protected
|
|
237
|
+
|
|
238
|
+
attr_reader :values
|
|
239
|
+
|
|
240
|
+
def raw_rows
|
|
241
|
+
sql, binds = to_sql_and_binds
|
|
242
|
+
@model.connection.select_all(sql, binds)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def pluck_value
|
|
246
|
+
row = raw_rows.first
|
|
247
|
+
row && row.values.first
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
private
|
|
251
|
+
|
|
252
|
+
def aggregate(function, column, options = {})
|
|
253
|
+
expression = column.is_a?(SQL::Literal) ? column.sql : SQL.column(@table_name, column)
|
|
254
|
+
row = select(SQL::Literal.new("#{function}(#{expression}) AS value")).reorder.send(:raw_rows).first
|
|
255
|
+
value = row && row["value"]
|
|
256
|
+
options[:integer] && value ? value.to_i : value
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def to_sql_and_binds
|
|
260
|
+
SQL.select(@table_name, @values)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def spawn_with(key, value)
|
|
264
|
+
values = duplicate_values
|
|
265
|
+
values[key] = value
|
|
266
|
+
self.class.new(@model, values)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def duplicate_values
|
|
270
|
+
{
|
|
271
|
+
where: @values[:where].dup, order: @values[:order].dup,
|
|
272
|
+
select: @values[:select].dup, joins: @values[:joins].dup,
|
|
273
|
+
limit: @values[:limit], offset: @values[:offset], distinct: @values[:distinct]
|
|
274
|
+
}
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def stringify_keys(attributes)
|
|
278
|
+
result = {}
|
|
279
|
+
attributes.each { |key, value| result[key.to_s] = value }
|
|
280
|
+
result
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def scope_for_create
|
|
284
|
+
attributes = {}
|
|
285
|
+
@values[:where].each do |fragment|
|
|
286
|
+
next unless fragment.sql =~ /\A\"[^\"]+\"\.\"([^\"]+)\" = \?\z/
|
|
287
|
+
attributes[Regexp.last_match(1)] = fragment.binds.first
|
|
288
|
+
end
|
|
289
|
+
attributes
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RufletRecord
|
|
4
|
+
class TableDefinition
|
|
5
|
+
attr_reader :name, :columns, :indexes, :foreign_keys
|
|
6
|
+
|
|
7
|
+
def initialize(name, id)
|
|
8
|
+
@name = name.to_s
|
|
9
|
+
@columns = []
|
|
10
|
+
@indexes = []
|
|
11
|
+
@foreign_keys = []
|
|
12
|
+
integer(:id, primary_key: true, auto_increment: true, null: false) unless id == false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def column(name, type, options = {})
|
|
16
|
+
@columns << [name.to_s, type.to_sym, options]
|
|
17
|
+
self
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def string(name, options = {}); column(name, :string, options); end
|
|
21
|
+
def text(name, options = {}); column(name, :text, options); end
|
|
22
|
+
def integer(name, options = {}); column(name, :integer, options); end
|
|
23
|
+
def float(name, options = {}); column(name, :float, options); end
|
|
24
|
+
def decimal(name, options = {}); column(name, :decimal, options); end
|
|
25
|
+
def boolean(name, options = {}); column(name, :boolean, options); end
|
|
26
|
+
def datetime(name, options = {}); column(name, :datetime, options); end
|
|
27
|
+
def date(name, options = {}); column(name, :date, options); end
|
|
28
|
+
def binary(name, options = {}); column(name, :binary, options); end
|
|
29
|
+
def json(name, options = {}); column(name, :json, options); end
|
|
30
|
+
|
|
31
|
+
def references(name, options = {})
|
|
32
|
+
column_options = options.dup
|
|
33
|
+
foreign_key = column_options.delete(:foreign_key)
|
|
34
|
+
index_option = column_options.delete(:index)
|
|
35
|
+
column_name = "#{name}_id"
|
|
36
|
+
column(column_name, :integer, column_options)
|
|
37
|
+
index(column_name, index_option.is_a?(Hash) ? index_option : {}) unless index_option == false
|
|
38
|
+
add_foreign_key(column_name, name, foreign_key) if foreign_key
|
|
39
|
+
end
|
|
40
|
+
alias belongs_to references
|
|
41
|
+
|
|
42
|
+
def timestamps(options = {})
|
|
43
|
+
datetime(:created_at, options)
|
|
44
|
+
datetime(:updated_at, options)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def index(columns, options = {})
|
|
48
|
+
@indexes << [Array(columns).map(&:to_s), options]
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def add_foreign_key(column_name, reference_name, options)
|
|
53
|
+
settings = options.is_a?(Hash) ? options : {}
|
|
54
|
+
target_table = settings[:to_table] || Inflector.pluralize(reference_name.to_s)
|
|
55
|
+
target_column = settings[:primary_key] || "id"
|
|
56
|
+
@foreign_keys << [
|
|
57
|
+
column_name.to_s,
|
|
58
|
+
target_table.to_s,
|
|
59
|
+
target_column.to_s,
|
|
60
|
+
settings[:on_delete],
|
|
61
|
+
settings[:on_update]
|
|
62
|
+
]
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class Schema
|
|
68
|
+
TYPE_SQL = {
|
|
69
|
+
string: "VARCHAR", text: "TEXT", integer: "INTEGER", float: "REAL",
|
|
70
|
+
decimal: "DECIMAL", boolean: "BOOLEAN", datetime: "DATETIME",
|
|
71
|
+
date: "DATE", binary: "BLOB", json: "TEXT"
|
|
72
|
+
}.freeze
|
|
73
|
+
|
|
74
|
+
class << self
|
|
75
|
+
attr_writer :connection
|
|
76
|
+
|
|
77
|
+
def connection
|
|
78
|
+
@connection || RufletRecord.connection
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def define(&block)
|
|
82
|
+
schema = new(connection)
|
|
83
|
+
schema.instance_eval(&block)
|
|
84
|
+
schema
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def initialize(connection = nil)
|
|
89
|
+
@connection = connection || RufletRecord.connection
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def create_table(name, options = {}, &block)
|
|
93
|
+
definition = TableDefinition.new(name, options.fetch(:id, true))
|
|
94
|
+
block.call(definition) if block
|
|
95
|
+
if options[:force]
|
|
96
|
+
drop_table(name, if_exists: true)
|
|
97
|
+
elsif options[:if_not_exists] && @connection.table_exists?(name)
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
columns = definition.columns.map { |column| column_sql(*column) }
|
|
101
|
+
definition.foreign_keys.each { |foreign_key| columns << foreign_key_sql(*foreign_key) }
|
|
102
|
+
@connection.execute("CREATE TABLE #{SQL.quote_identifier(name)} (#{columns.join(', ')})")
|
|
103
|
+
definition.indexes.each { |columns_value, index_options| add_index(name, columns_value, index_options) }
|
|
104
|
+
reset_models
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def drop_table(name, options = {})
|
|
108
|
+
clause = options[:if_exists] ? " IF EXISTS" : ""
|
|
109
|
+
@connection.execute("DROP TABLE#{clause} #{SQL.quote_identifier(name)}")
|
|
110
|
+
reset_models
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def add_column(table, name, type, options = {})
|
|
114
|
+
@connection.execute("ALTER TABLE #{SQL.quote_identifier(table)} ADD COLUMN #{column_sql(name, type, options)}")
|
|
115
|
+
reset_models
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def add_index(table, columns, options = {})
|
|
119
|
+
names = Array(columns).map(&:to_s)
|
|
120
|
+
index_name = options[:name] || "index_#{table}_on_#{names.join('_and_')}"
|
|
121
|
+
unique = options[:unique] ? "UNIQUE " : ""
|
|
122
|
+
quoted_columns = names.map { |name| SQL.quote_identifier(name) }.join(", ")
|
|
123
|
+
@connection.execute("CREATE #{unique}INDEX #{SQL.quote_identifier(index_name)} ON #{SQL.quote_identifier(table)} (#{quoted_columns})")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def remove_index(table, columns = nil, options = {})
|
|
127
|
+
names = Array(columns).map(&:to_s)
|
|
128
|
+
index_name = options[:name] || "index_#{table}_on_#{names.join('_and_')}"
|
|
129
|
+
@connection.execute("DROP INDEX #{SQL.quote_identifier(index_name)}")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def rename_table(old_name, new_name)
|
|
133
|
+
@connection.execute("ALTER TABLE #{SQL.quote_identifier(old_name)} RENAME TO #{SQL.quote_identifier(new_name)}")
|
|
134
|
+
reset_models
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def column_sql(name, type, options)
|
|
140
|
+
sql_type = TYPE_SQL[type.to_sym]
|
|
141
|
+
raise ArgumentError, "unknown column type: #{type}" unless sql_type
|
|
142
|
+
sql = "#{SQL.quote_identifier(name)} #{sql_type}"
|
|
143
|
+
if options[:primary_key]
|
|
144
|
+
sql << " PRIMARY KEY"
|
|
145
|
+
sql << " AUTOINCREMENT" if options[:auto_increment]
|
|
146
|
+
end
|
|
147
|
+
sql << " NOT NULL" if options[:null] == false
|
|
148
|
+
sql << " UNIQUE" if options[:unique]
|
|
149
|
+
sql << " DEFAULT #{quote_default(options[:default])}" if options.key?(:default)
|
|
150
|
+
sql
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def quote_default(value)
|
|
154
|
+
return "NULL" if value.nil?
|
|
155
|
+
return value.to_s if value.is_a?(Numeric)
|
|
156
|
+
return value ? "1" : "0" if value == true || value == false
|
|
157
|
+
return value.sql if value.is_a?(SQL::Literal)
|
|
158
|
+
|
|
159
|
+
"'#{value.to_s.gsub("'", "''")}'"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def foreign_key_sql(column, target_table, target_column, on_delete, on_update)
|
|
163
|
+
sql = "FOREIGN KEY (#{SQL.quote_identifier(column)}) REFERENCES #{SQL.quote_identifier(target_table)} (#{SQL.quote_identifier(target_column)})"
|
|
164
|
+
sql << " ON DELETE #{foreign_key_action(on_delete)}" if on_delete
|
|
165
|
+
sql << " ON UPDATE #{foreign_key_action(on_update)}" if on_update
|
|
166
|
+
sql
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def foreign_key_action(value)
|
|
170
|
+
action = value.to_s.upcase.tr("_", " ")
|
|
171
|
+
allowed = ["CASCADE", "RESTRICT", "SET NULL", "SET DEFAULT", "NO ACTION"]
|
|
172
|
+
raise ArgumentError, "invalid foreign key action: #{value}" unless allowed.include?(action)
|
|
173
|
+
action
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def reset_models
|
|
177
|
+
Base.descendants.each(&:reset_column_information) if RufletRecord.const_defined?(:Base)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
class Migration
|
|
182
|
+
class << self
|
|
183
|
+
attr_accessor :connection
|
|
184
|
+
|
|
185
|
+
def migrate(direction = :up)
|
|
186
|
+
migration = new
|
|
187
|
+
if migration.respond_to?(direction)
|
|
188
|
+
migration.public_send(direction)
|
|
189
|
+
elsif direction.to_sym == :up && migration.respond_to?(:change)
|
|
190
|
+
migration.change
|
|
191
|
+
else
|
|
192
|
+
raise Error, "migration does not implement #{direction}"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def connection
|
|
198
|
+
self.class.connection || RufletRecord.connection
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def method_missing(name, *args, &block)
|
|
202
|
+
schema = Schema.new(connection)
|
|
203
|
+
return schema.public_send(name, *args, &block) if schema.respond_to?(name)
|
|
204
|
+
super
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def respond_to_missing?(name, include_private = false)
|
|
208
|
+
Schema.new(connection).respond_to?(name, include_private) || super
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RufletRecord
|
|
4
|
+
module SQL
|
|
5
|
+
class Literal
|
|
6
|
+
attr_reader :sql
|
|
7
|
+
|
|
8
|
+
def initialize(sql)
|
|
9
|
+
@sql = sql.to_s.freeze
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_s
|
|
13
|
+
@sql
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class Fragment
|
|
18
|
+
attr_reader :sql, :binds
|
|
19
|
+
|
|
20
|
+
def initialize(sql, binds)
|
|
21
|
+
@sql = sql.to_s.freeze
|
|
22
|
+
@binds = binds.freeze
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
|
|
28
|
+
def quote_identifier(name)
|
|
29
|
+
parts = name.to_s.split(".")
|
|
30
|
+
parts.map { |part| "\"#{part.gsub('"', '""')}\"" }.join(".")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def column(table, name)
|
|
34
|
+
return name.sql if name.is_a?(Literal)
|
|
35
|
+
|
|
36
|
+
value = name.to_s
|
|
37
|
+
value.include?(".") ? quote_identifier(value) : "#{quote_identifier(table)}.#{quote_identifier(value)}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def columns(table, values)
|
|
41
|
+
selected = values.nil? || values.empty? ? [Literal.new("#{quote_identifier(table)}.*")] : values
|
|
42
|
+
selected.map { |value| column(table, value) }.join(", ")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def predicate(table, conditions, binds)
|
|
46
|
+
if conditions.is_a?(Fragment)
|
|
47
|
+
return conditions
|
|
48
|
+
elsif conditions.is_a?(Hash)
|
|
49
|
+
parts = []
|
|
50
|
+
values = []
|
|
51
|
+
conditions.each do |name, value|
|
|
52
|
+
identifier = column(table, name)
|
|
53
|
+
if value.nil?
|
|
54
|
+
parts << "#{identifier} IS NULL"
|
|
55
|
+
elsif value.is_a?(Array)
|
|
56
|
+
if value.empty?
|
|
57
|
+
parts << "1 = 0"
|
|
58
|
+
else
|
|
59
|
+
parts << "#{identifier} IN (#{(["?"] * value.length).join(', ')})"
|
|
60
|
+
values.concat(value)
|
|
61
|
+
end
|
|
62
|
+
elsif value.is_a?(Range)
|
|
63
|
+
if value.exclude_end?
|
|
64
|
+
parts << "(#{identifier} >= ? AND #{identifier} < ?)"
|
|
65
|
+
else
|
|
66
|
+
parts << "#{identifier} BETWEEN ? AND ?"
|
|
67
|
+
end
|
|
68
|
+
values << value.begin << value.end
|
|
69
|
+
else
|
|
70
|
+
parts << "#{identifier} = ?"
|
|
71
|
+
values << value
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
return Fragment.new(parts.empty? ? "1 = 1" : parts.join(" AND "), values)
|
|
75
|
+
elsif conditions.is_a?(String)
|
|
76
|
+
expected = conditions.count("?")
|
|
77
|
+
if expected != binds.length
|
|
78
|
+
raise ArgumentError, "wrong number of bind values (#{binds.length} for #{expected})"
|
|
79
|
+
end
|
|
80
|
+
return Fragment.new(conditions, binds)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
raise ArgumentError, "where conditions must be a Hash, String, or SQL fragment"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def negate(fragment)
|
|
87
|
+
Fragment.new("NOT (#{fragment.sql})", fragment.binds)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def order(table, values)
|
|
91
|
+
values.map do |value|
|
|
92
|
+
if value.is_a?(Hash)
|
|
93
|
+
value.map do |name, direction|
|
|
94
|
+
normalized = direction.to_s.upcase
|
|
95
|
+
raise ArgumentError, "invalid order direction: #{direction}" unless %w[ASC DESC].include?(normalized)
|
|
96
|
+
"#{column(table, name)} #{normalized}"
|
|
97
|
+
end.join(", ")
|
|
98
|
+
elsif value.is_a?(Symbol)
|
|
99
|
+
"#{column(table, value)} ASC"
|
|
100
|
+
elsif value.is_a?(Literal)
|
|
101
|
+
value.sql
|
|
102
|
+
else
|
|
103
|
+
parse_order_string(table, value.to_s)
|
|
104
|
+
end
|
|
105
|
+
end.join(", ")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parse_order_string(table, value)
|
|
109
|
+
value.split(",").map do |piece|
|
|
110
|
+
match = /\A\s*([A-Za-z_][A-Za-z0-9_.]*)(?:\s+(ASC|DESC))?\s*\z/i.match(piece)
|
|
111
|
+
raise ArgumentError, "unsafe order expression: #{value.inspect}" unless match
|
|
112
|
+
direction = match[2] ? " #{match[2].upcase}" : " ASC"
|
|
113
|
+
"#{column(table, match[1])}#{direction}"
|
|
114
|
+
end.join(", ")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def select(table, values)
|
|
118
|
+
sql = +"SELECT "
|
|
119
|
+
sql << "DISTINCT " if values[:distinct]
|
|
120
|
+
sql << columns(table, values[:select])
|
|
121
|
+
sql << " FROM #{quote_identifier(table)}"
|
|
122
|
+
sql << " #{values[:joins].join(' ')}" unless values[:joins].empty?
|
|
123
|
+
binds = []
|
|
124
|
+
append_wheres(sql, binds, values[:where])
|
|
125
|
+
unless values[:order].empty?
|
|
126
|
+
sql << " ORDER BY #{order(table, values[:order])}"
|
|
127
|
+
end
|
|
128
|
+
if values[:limit]
|
|
129
|
+
sql << " LIMIT ?"
|
|
130
|
+
binds << values[:limit]
|
|
131
|
+
elsif values[:offset]
|
|
132
|
+
sql << " LIMIT -1"
|
|
133
|
+
end
|
|
134
|
+
if values[:offset]
|
|
135
|
+
sql << " OFFSET ?"
|
|
136
|
+
binds << values[:offset]
|
|
137
|
+
end
|
|
138
|
+
[sql, binds]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def insert(table, attributes)
|
|
142
|
+
names = attributes.keys
|
|
143
|
+
sql = "INSERT INTO #{quote_identifier(table)}"
|
|
144
|
+
if names.empty?
|
|
145
|
+
return ["#{sql} DEFAULT VALUES", []]
|
|
146
|
+
end
|
|
147
|
+
quoted = names.map { |name| quote_identifier(name) }.join(", ")
|
|
148
|
+
placeholders = (["?"] * names.length).join(", ")
|
|
149
|
+
["#{sql} (#{quoted}) VALUES (#{placeholders})", names.map { |name| attributes[name] }]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def update(table, attributes, wheres)
|
|
153
|
+
assignments = attributes.keys.map { |name| "#{quote_identifier(name)} = ?" }.join(", ")
|
|
154
|
+
binds = attributes.keys.map { |name| attributes[name] }
|
|
155
|
+
sql = "UPDATE #{quote_identifier(table)} SET #{assignments}"
|
|
156
|
+
append_wheres(sql, binds, wheres)
|
|
157
|
+
[sql, binds]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def delete(table, wheres)
|
|
161
|
+
sql = "DELETE FROM #{quote_identifier(table)}"
|
|
162
|
+
binds = []
|
|
163
|
+
append_wheres(sql, binds, wheres)
|
|
164
|
+
[sql, binds]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def append_wheres(sql, binds, wheres)
|
|
168
|
+
return if wheres.empty?
|
|
169
|
+
|
|
170
|
+
sql << " WHERE #{wheres.map { |fragment| "(#{fragment.sql})" }.join(' AND ')}"
|
|
171
|
+
wheres.each { |fragment| binds.concat(fragment.binds) }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|