edgeway 0.1.0 → 0.2.0

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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +270 -9
  4. data/js/cloudflare-d1-sessions.js +68 -0
  5. data/js/cloudflare.js +123 -22
  6. data/js/cors.js +15 -16
  7. data/js/headers.js +16 -0
  8. data/js/jwt.js +1 -1
  9. data/lib/edgeway/application.rb +6 -3
  10. data/lib/edgeway/cli/build/app_config.rb +39 -0
  11. data/lib/edgeway/cli/build/app_files.rb +40 -0
  12. data/lib/edgeway/cli/build/database_config.rb +92 -0
  13. data/lib/edgeway/cli/build/view_compiler.rb +191 -0
  14. data/lib/edgeway/cli/build.rb +36 -11
  15. data/lib/edgeway/cli/generate.rb +174 -26
  16. data/lib/edgeway/cli/new.rb +1 -1
  17. data/lib/edgeway/cli/test.rb +4 -1
  18. data/lib/edgeway/cli.rb +5 -4
  19. data/lib/edgeway/controller/base.rb +12 -2
  20. data/lib/edgeway/controller/dispatcher.rb +11 -1
  21. data/lib/edgeway/controller/rendering.rb +24 -4
  22. data/lib/edgeway/db/adapter.rb +38 -0
  23. data/lib/edgeway/db/memory.rb +9 -2
  24. data/lib/edgeway/db/query_stats.rb +46 -0
  25. data/lib/edgeway/db/sqlite.rb +73 -9
  26. data/lib/edgeway/errors.rb +22 -0
  27. data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +38 -0
  28. data/lib/edgeway/generators/app/templates/.ruby-version.tt +1 -0
  29. data/lib/edgeway/generators/app/templates/README.md.tt +6 -2
  30. data/lib/edgeway/generators/app/templates/app/views/layouts/application.html.erb +11 -0
  31. data/lib/edgeway/generators/app/templates/bin/build.tt +1 -1
  32. data/lib/edgeway/generators/app/templates/bin/deploy.tt +1 -1
  33. data/lib/edgeway/generators/app/templates/bin/test.tt +7 -0
  34. data/lib/edgeway/generators/app/templates/config/database.yml +17 -0
  35. data/lib/edgeway/generators/app/templates/mise.toml.tt +1 -1
  36. data/lib/edgeway/generators/app/templates/wrangler.toml.tt +7 -0
  37. data/lib/edgeway/http/auth/stub.rb +1 -3
  38. data/lib/edgeway/http/request.rb +2 -22
  39. data/lib/edgeway/inflector.rb +19 -0
  40. data/lib/edgeway/model/attributes.rb +93 -10
  41. data/lib/edgeway/model/base.rb +50 -5
  42. data/lib/edgeway/model/connection_registry.rb +25 -0
  43. data/lib/edgeway/model/macros.rb +5 -1
  44. data/lib/edgeway/model/persistence.rb +18 -11
  45. data/lib/edgeway/model/relation.rb +106 -56
  46. data/lib/edgeway/model/validations.rb +29 -23
  47. data/lib/edgeway/routing/route.rb +1 -6
  48. data/lib/edgeway/server.rb +12 -0
  49. data/lib/edgeway/service/cloudflare/d1.rb +39 -10
  50. data/lib/edgeway/test.rb +29 -8
  51. data/lib/edgeway/version.rb +1 -1
  52. data/lib/edgeway/view.rb +24 -0
  53. data/lib/edgeway.rb +3 -0
  54. metadata +17 -4
  55. data/lib/edgeway/cli/app_config.rb +0 -35
  56. data/lib/edgeway/cli/app_files.rb +0 -37
@@ -8,16 +8,33 @@ module Edgeway
8
8
  INT_MAX = 2147483647
9
9
  INT_MIN = -2147483648
10
10
 
11
+ PRIMARY = "primary"
12
+
13
+ def self.registry = (@registry ||= ConnectionRegistry.new)
14
+
15
+ def self.connect(name, adapter) = registry.connect(name, adapter)
16
+
17
+ def self.disconnect(name) = registry.disconnect(name)
18
+
11
19
  def self.connection=(adapter)
12
- @connection = adapter
20
+ connect(PRIMARY, adapter)
13
21
  end
14
22
 
15
- def self.connection
16
- @connection || raise(ConfigurationError, "Edgeway::Model.connection is not set")
23
+ def self.connected? = registry.connected?(PRIMARY)
24
+
25
+ def self.connection = connection_for(PRIMARY)
26
+
27
+ def self.connection_for(name) = registry.fetch(name)
28
+
29
+ def self.database_names = registry.names
30
+
31
+ def self.query_stats(name = PRIMARY)
32
+ connection_for(name).query_stats
17
33
  end
18
34
 
19
35
  # Runs the block with writes queued instead of executed, then sends them
20
- # all as one atomic batch (D1 batch(): one SQL transaction, all or none).
36
+ # all as one atomic batch (one SQL transaction, all or none).
37
+ # A batch belongs to the database of its first write.
21
38
  def self.batch
22
39
  if @batch_queue
23
40
  yield
@@ -25,14 +42,16 @@ module Edgeway
25
42
  end
26
43
  queue = []
27
44
  @batch_queue = queue
45
+ @batch_database = nil
28
46
  begin
29
47
  yield
30
48
  rescue => e
31
49
  @batch_queue = nil
32
50
  raise e
33
51
  end
52
+ database = @batch_database
34
53
  @batch_queue = nil
35
- queue.empty? ? [] : connection.batch(queue)
54
+ queue.empty? ? [] : connection_for(database).batch(queue)
36
55
  end
37
56
 
38
57
  def self.batching? = !@batch_queue.nil?
@@ -40,16 +59,42 @@ module Edgeway
40
59
  # Every model write but an INSERT goes through here: executed at once, or
41
60
  # queued inside batch. A queued write answers a placeholder result, as its
42
61
  # counts are not known until the batch is sent.
43
- def self.write(sql, binds)
62
+ def self.write(sql, binds, database: PRIMARY)
63
+ name = database.to_s
44
64
  queue = @batch_queue
45
65
  if queue.nil?
46
- connection.execute(sql, binds)
66
+ connection_for(name).execute(sql, binds)
47
67
  else
68
+ join_batch(name)
48
69
  queue << [sql, binds]
49
70
  { "changes" => 0, "last_insert_id" => 0 }
50
71
  end
51
72
  end
52
73
 
74
+ def self.join_batch(name)
75
+ current = @batch_database
76
+ if current.nil?
77
+ @batch_database = name
78
+ elsif current != name
79
+ raise ArgumentError, "Model.batch spans databases (#{current} and #{name}); a batch runs on one database"
80
+ end
81
+ end
82
+
83
+ def self.select_all(sql, binds = [], database: PRIMARY)
84
+ connection_for(database).select_all(sql, binds.map { |b| database_value(b) })
85
+ end
86
+
87
+ def self.execute(sql, binds = [], database: PRIMARY)
88
+ write(sql, binds.map { |b| database_value(b) }, database: database)
89
+ end
90
+
91
+ def self.exec(sql, database: PRIMARY)
92
+ raise ArgumentError, "Model.exec can't run inside Model.batch" if batching?
93
+ raise ArgumentError, "Model.exec needs SQL text" unless sql.is_a?(String) && !sql.strip.empty?
94
+ connection_for(database).exec(sql)
95
+ nil
96
+ end
97
+
53
98
  def self.to_int(v)
54
99
  n = v.is_a?(Integer) ? v : v.to_s.to_i
55
100
  n > INT_MAX || n < INT_MIN ? nil : n
@@ -63,13 +108,51 @@ module Edgeway
63
108
  when :integer then to_int(v)
64
109
  when :float then v.is_a?(Float) ? v : v.to_s.to_f
65
110
  when :boolean then v == true || TRUE_STRINGS.include?(v.to_s)
111
+ when :bigint then to_bigint(v)
112
+ when :binary then to_binary(v)
66
113
  else v.to_s
67
114
  end
68
115
  end
69
116
 
70
- def self.to_db(v)
71
- return connection.quote_boolean(v) if v == true || v == false
72
- v
117
+ INT64_MAX_DIGITS = "9223372036854775807"
118
+ INT64_MIN_DIGITS = "9223372036854775808"
119
+
120
+ # A bigint is held as its decimal String, as a number beyond 32 bits can't
121
+ # be an Integer in wasm.
122
+ def self.to_bigint(value)
123
+ text = value.to_s
124
+ return nil unless text.match?(/\A-?[0-9]+\z/)
125
+ negative = text.start_with?("-")
126
+ digits = text.sub(/\A-?0*/, "")
127
+ digits = "0" if digits.empty?
128
+ return nil unless within_int64?(digits, negative)
129
+ negative && digits != "0" ? "-#{digits}" : digits
130
+ end
131
+
132
+ def self.within_int64?(digits, negative)
133
+ limit = negative ? INT64_MIN_DIGITS : INT64_MAX_DIGITS
134
+ return digits.length < limit.length || (digits.length == limit.length && digits <= limit)
135
+ end
136
+
137
+ def self.to_binary(value)
138
+ return value.to_s.b unless value.is_a?(Hash)
139
+ return nil unless value.key?("$blob")
140
+ value["$blob"].to_s.unpack1("m0").b
141
+ rescue ArgumentError
142
+ nil
143
+ end
144
+
145
+ def self.encode_base64(bytes) = [bytes].pack("m0")
146
+
147
+ def self.blob(bytes)
148
+ { "$blob" => encode_base64(bytes) }
149
+ end
150
+
151
+ def self.database_value(value, type = nil)
152
+ return connection.quote_boolean(value) if value == true || value == false
153
+ return value.getutc.iso8601(3) if value.is_a?(Time)
154
+ return blob(value) if type == :binary && value.is_a?(String)
155
+ value
73
156
  end
74
157
 
75
158
  def self.underscore(name)
@@ -40,6 +40,26 @@ module Edgeway
40
40
  @table_name || Inflector.pluralize(Model.underscore(name.split("::").last))
41
41
  end
42
42
 
43
+ def self.database=(name)
44
+ raise ArgumentError, "database name can't be blank" if name.nil? || name.to_s.empty?
45
+ @database = name.to_s
46
+ end
47
+
48
+ def self.own_database = @database
49
+
50
+ def self.database
51
+ klass = self
52
+ while klass
53
+ name = klass.own_database
54
+ return name unless name.nil?
55
+ break if klass == Base
56
+ klass = klass.superclass
57
+ end
58
+ Model::PRIMARY
59
+ end
60
+
61
+ def self.connection = Model.connection_for(database)
62
+
43
63
  def self.attribute_types = (@attribute_types ||= {})
44
64
  def self.attribute_defaults = (@attribute_defaults ||= {})
45
65
  def self.belongs_to_list = (@belongs_to ||= [])
@@ -82,12 +102,17 @@ module Edgeway
82
102
  ["id"] + attribute_types.keys
83
103
  end
84
104
 
105
+ def self.id_type = (@id_type ||= :integer)
106
+ def self.id_type=(type)
107
+ @id_type = type
108
+ end
109
+
85
110
  def self.type_of(col)
86
- col == "id" ? :integer : attribute_types[col]
111
+ col == "id" ? id_type : attribute_types[col]
87
112
  end
88
113
 
89
114
  def self.all = Relation.new(self)
90
- def self.where(conditions) = all.where(conditions)
115
+ def self.where(conditions, *args) = all.where(conditions, *args)
91
116
  def self.order(col, dir = :asc) = all.order(col, dir)
92
117
  def self.reorder(col, dir = :asc) = all.reorder(col, dir)
93
118
  def self.delete_all = all.delete_all
@@ -100,6 +125,7 @@ module Edgeway
100
125
  def self.pluck(col) = all.pluck(col)
101
126
  def self.first = all.first
102
127
  def self.find_by(conditions) = all.find_by(conditions)
128
+ def self.find_by_sql(sql, binds = []) = Model.select_all(sql, binds, database: database).map { |row| instantiate(row) }
103
129
  def self.find(id) = all.find(id)
104
130
 
105
131
  def self.find_or_create_by(conditions) = all.find_or_create_by(conditions)
@@ -148,16 +174,27 @@ module Edgeway
148
174
  types = self.class.attribute_types
149
175
  values.each do |k, v|
150
176
  key = k.to_s
151
- write_attribute(key, v) if types.key?(key)
177
+ write_attribute(key, param_value(types[key], v, attrs)) if types.key?(key)
152
178
  end
153
179
  end
154
180
 
181
+ def param_value(type, value, attrs)
182
+ return value unless type == :binary && attrs.is_a?(HTTP::Parameters) && value.is_a?(String)
183
+ { "$blob" => value }
184
+ end
185
+
155
186
  def load_row(row)
156
- self.class.columns.each { |col| @attributes[col] = Model.cast(self.class.type_of(col), row[col]) }
157
187
  @raw = {}
188
+ self.class.columns.each { |column| load_column(column, row[column]) }
158
189
  @persisted = true
159
190
  end
160
191
 
192
+ def load_column(column, value)
193
+ casted = Model.cast(self.class.type_of(column), value)
194
+ @attributes[column] = casted
195
+ @raw[column] = value if casted.nil? && !value.nil?
196
+ end
197
+
161
198
  def write_attribute(col, v)
162
199
  @raw[col] = v
163
200
  @attributes[col] = Model.cast(self.class.type_of(col), v)
@@ -165,9 +202,17 @@ module Edgeway
165
202
 
166
203
  def as_json
167
204
  h = {}
168
- self.class.columns.each { |col| h[col] = @attributes[col] }
205
+ self.class.columns.each { |column| h[column] = json_value(column) }
169
206
  h
170
207
  end
208
+
209
+ private
210
+
211
+ def json_value(column)
212
+ value = @attributes[column]
213
+ return value unless self.class.type_of(column) == :binary && !value.nil?
214
+ Model.encode_base64(value)
215
+ end
171
216
  end
172
217
  end
173
218
  end
@@ -0,0 +1,25 @@
1
+ module Edgeway
2
+ module Model
3
+ class ConnectionRegistry
4
+ def initialize
5
+ @adapters = {}
6
+ end
7
+
8
+ def connect(name, adapter)
9
+ @adapters[name.to_s] = adapter
10
+ end
11
+
12
+ def disconnect(name)
13
+ @adapters.delete(name.to_s)
14
+ end
15
+
16
+ def connected?(name) = @adapters.key?(name.to_s)
17
+
18
+ def names = @adapters.keys
19
+
20
+ def fetch(name)
21
+ @adapters[name.to_s] || raise(ConfigurationError, "no database #{name}: it is not under databases: of this environment in config/database.yml")
22
+ end
23
+ end
24
+ end
25
+ end
@@ -2,13 +2,17 @@ module Edgeway
2
2
  module Model
3
3
  module Macros
4
4
  def attribute(name, type, default: nil)
5
+ if name.to_s == "id"
6
+ self.id_type = type
7
+ return
8
+ end
5
9
  attribute_types[name.to_s] = type
6
10
  attribute_defaults[name.to_s] = default unless default.nil?
7
11
  define_method(name) { @attributes[name.to_s] }
8
12
  define_method("#{name}=") { |v| write_attribute(name.to_s, v) }
9
13
  end
10
14
 
11
- # created_at / updated_at, as ISO 8601 text: SQLite (so D1) has no datetime
15
+ # created_at / updated_at, as ISO 8601 text: SQLite has no datetime
12
16
  # column type, the migrations declare them TEXT.
13
17
  def timestamps
14
18
  attribute :created_at, :datetime
@@ -53,6 +53,7 @@ module Edgeway
53
53
  end
54
54
 
55
55
  def destroy
56
+ check_dependents_database
56
57
  if self.class.dependents.empty?
57
58
  delete_row
58
59
  else
@@ -86,10 +87,8 @@ module Edgeway
86
87
  cfg = nil
87
88
  self.class.has_manys.each { |h| cfg = h if h.name == name }
88
89
  raise ArgumentError, "no has_many #{name}" if cfg.nil?
89
- # .dup: since matz/spinel 44119ab30 (Strings shared by reference),
90
- # Spinel refuses this String as it is, or emits C that doesn't compile.
91
- fk = cfg.foreign_key.nil? ? "#{Edgeway::Model.underscore(self.class.name.split("::").last)}_id" : cfg.foreign_key.dup
92
- pk = self.class.column(cfg.primary_key.nil? ? "id" : cfg.primary_key.dup)
90
+ fk = cfg.foreign_key.nil? ? "#{Edgeway::Model.underscore(self.class.name.split("::").last)}_id" : cfg.foreign_key
91
+ pk = self.class.column(cfg.primary_key.nil? ? "id" : cfg.primary_key)
93
92
  Edgeway::Model::Base.model_named(name, self.class.name).where(fk => @attributes[pk])
94
93
  end
95
94
 
@@ -108,26 +107,34 @@ module Edgeway
108
107
 
109
108
  private
110
109
 
110
+ def check_dependents_database
111
+ self.class.dependents.each do |dependent|
112
+ child = Edgeway::Model::Base.model_named(dependent.name, self.class.name)
113
+ next if child.database == self.class.database
114
+ raise ArgumentError, "#{self.class.name} has_many :#{dependent.name}, dependent: :#{dependent.mode}, on database #{child.database}; destroy can't delete across databases atomically"
115
+ end
116
+ end
117
+
111
118
  def delete_row
112
- conn = Model.connection
113
- Model.write("DELETE FROM #{self.class.table_name} WHERE id = #{conn.placeholder(1)}", [id])
119
+ conn = self.class.connection
120
+ Model.write("DELETE FROM #{self.class.table_name} WHERE id = #{conn.placeholder(1)}", [id], database: self.class.database)
114
121
  end
115
122
 
116
123
  def update_row
117
- conn = Model.connection
124
+ conn = self.class.connection
118
125
  cols = self.class.attribute_types.keys
119
- values = cols.map { |c| Model.to_db(@attributes[c]) }
126
+ values = cols.map { |c| Model.database_value(@attributes[c], self.class.type_of(c)) }
120
127
  sets = cols.each_with_index.map { |c, i| "#{c} = #{conn.placeholder(i + 1)}" }.join(", ")
121
128
  Model.write("UPDATE #{self.class.table_name} SET #{sets} WHERE id = #{conn.placeholder(cols.size + 1)}",
122
- values + [id])
129
+ values + [id], database: self.class.database)
123
130
  end
124
131
 
125
132
  def insert_row
126
133
  raise ArgumentError, "create isn't supported inside Model.batch" if Model.batching?
127
- conn = Model.connection
134
+ conn = self.class.connection
128
135
  table = self.class.table_name
129
136
  cols = self.class.attribute_types.keys.reject { |c| @attributes[c].nil? }
130
- values = cols.map { |c| Model.to_db(@attributes[c]) }
137
+ values = cols.map { |c| Model.database_value(@attributes[c], self.class.type_of(c)) }
131
138
  sql = "INSERT INTO #{table}"
132
139
  if cols.empty?
133
140
  sql += " DEFAULT VALUES"
@@ -3,12 +3,17 @@ module Edgeway
3
3
  class Relation
4
4
  # eager is the associations eager_load names (empty: none); records, when
5
5
  # set, are what to_a answers instead of a query (an eager-loaded has_many).
6
+ # conds are the WHERE terms (each with its own "?"s) and binds their
7
+ # values in order; eq_cols/eq_vals are the plain equality terms, which
8
+ # build copies into the new record.
6
9
  attr_writer :records
7
10
 
8
- def initialize(model, where_cols = [], binds = [], order = [], limit = nil, offset = nil, eager = [])
11
+ def initialize(model, conds = [], binds = [], eq_cols = [], eq_vals = [], order = [], limit = nil, offset = nil, eager = [])
9
12
  @model = model
10
- @where_cols = where_cols
13
+ @conds = conds
11
14
  @binds = binds
15
+ @eq_cols = eq_cols
16
+ @eq_vals = eq_vals
12
17
  @order = order
13
18
  @limit = limit
14
19
  @offset = offset
@@ -16,37 +21,45 @@ module Edgeway
16
21
  @records = nil
17
22
  end
18
23
 
19
- def where(conditions)
20
- cols = @where_cols.dup
24
+ def where(conditions, *args)
25
+ conds = @conds.dup
21
26
  binds = @binds.dup
22
- conditions.each do |k, v|
23
- col = @model.column(k)
24
- casted = Model.cast(@model.type_of(col), v)
25
- # An integer outside 32 bits can't be held as one; it goes as its
26
- # text, which SQLite compares as the number it is, so nothing
27
- # matches (a find on it is a 404).
28
- casted = v.to_s if casted.nil? && !v.nil?
29
- cols << col
30
- binds << Model.to_db(casted)
27
+ eq_cols = @eq_cols.dup
28
+ eq_vals = @eq_vals.dup
29
+ if conditions.is_a?(String)
30
+ add_fragment(conditions, args, conds, binds)
31
+ else
32
+ raise ArgumentError, "where(Hash) takes no binds" unless args.empty?
33
+ conditions.each do |k, v|
34
+ column = @model.column(k)
35
+ type = @model.type_of(column)
36
+ if v.is_a?(Array)
37
+ add_in(column, type, v, conds, binds)
38
+ elsif v.is_a?(Range)
39
+ add_range(column, type, v, conds, binds)
40
+ else
41
+ add_equality(column, type, v, conds, binds, eq_cols, eq_vals)
42
+ end
43
+ end
31
44
  end
32
- Relation.new(@model, cols, binds, @order, @limit, @offset, @eager)
45
+ Relation.new(@model, conds, binds, eq_cols, eq_vals, @order, @limit, @offset, @eager)
33
46
  end
34
47
 
35
48
  def order(col, dir = :asc)
36
- Relation.new(@model, @where_cols, @binds, @order + [order_term(col, dir)], @limit, @offset, @eager)
49
+ Relation.new(@model, @conds, @binds, @eq_cols, @eq_vals, @order + [order_term(col, dir)], @limit, @offset, @eager)
37
50
  end
38
51
 
39
52
  # Replaces all the terms.
40
53
  def reorder(col, dir = :asc)
41
- Relation.new(@model, @where_cols, @binds, [order_term(col, dir)], @limit, @offset, @eager)
54
+ Relation.new(@model, @conds, @binds, @eq_cols, @eq_vals, [order_term(col, dir)], @limit, @offset, @eager)
42
55
  end
43
56
 
44
57
  def limit(n)
45
- Relation.new(@model, @where_cols, @binds, @order, count_arg("limit", n), @offset, @eager)
58
+ Relation.new(@model, @conds, @binds, @eq_cols, @eq_vals, @order, count_arg("limit", n), @offset, @eager)
46
59
  end
47
60
 
48
61
  def offset(n)
49
- Relation.new(@model, @where_cols, @binds, @order, @limit, count_arg("offset", n), @eager)
62
+ Relation.new(@model, @conds, @binds, @eq_cols, @eq_vals, @order, @limit, count_arg("offset", n), @eager)
50
63
  end
51
64
 
52
65
  # Loads the has_manys and belongs_tos named of every record with the one
@@ -59,12 +72,12 @@ module Edgeway
59
72
  names.each do |n|
60
73
  eager << n.to_s unless eager.include?(n.to_s)
61
74
  end
62
- Relation.new(@model, @where_cols, @binds, @order, @limit, @offset, eager)
75
+ Relation.new(@model, @conds, @binds, @eq_cols, @eq_vals, @order, @limit, @offset, eager)
63
76
  end
64
77
 
65
78
  # Same conditions, but to_a answers these records.
66
79
  def loaded(records)
67
- relation = Relation.new(@model, @where_cols, @binds, @order, @limit, @offset)
80
+ relation = Relation.new(@model, @conds, @binds, @eq_cols, @eq_vals, @order, @limit, @offset)
68
81
  relation.records = records
69
82
  relation
70
83
  end
@@ -73,12 +86,12 @@ module Edgeway
73
86
  return @records unless @records.nil?
74
87
  return eager_records unless @eager.empty?
75
88
  sql, binds = select(@model.columns.join(", "), true)
76
- Model.connection.select_all(sql, binds).map { |row| @model.instantiate(row) }
89
+ @model.connection.select_all(sql, binds).map { |row| @model.instantiate(row) }
77
90
  end
78
91
 
79
92
  def count
80
93
  sql, binds = select("1", false)
81
- Model.connection.select_all("SELECT COUNT(*) AS n FROM (#{sql})", binds)[0]["n"].to_i
94
+ @model.connection.select_all("SELECT COUNT(*) AS n FROM (#{sql})", binds)[0]["n"].to_i
82
95
  end
83
96
 
84
97
  def exists? = limit(1).count > 0
@@ -87,16 +100,14 @@ module Edgeway
87
100
  name = @model.column(col)
88
101
  type = @model.type_of(name)
89
102
  sql, binds = select(name, true)
90
- Model.connection.select_all(sql, binds).map { |row| Model.cast(type, row[name]) }
103
+ @model.connection.select_all(sql, binds).map { |row| Model.cast(type, row[name]) }
91
104
  end
92
105
 
93
106
  def first = (@order.empty? ? order(:id) : self).limit(1).to_a.first
94
107
 
95
108
  def delete_all
96
109
  raise ArgumentError, "delete_all doesn't support limit/offset" if @limit || @offset
97
- conn = Model.connection
98
- where_sql, _marks = where_clause(conn)
99
- Model.write("DELETE FROM #{@model.table_name}#{where_sql}", @binds.compact)["changes"]
110
+ Model.write("DELETE FROM #{@model.table_name}#{where_clause}", @binds, database: @model.database)["changes"]
100
111
  end
101
112
 
102
113
  def destroy_all
@@ -110,7 +121,7 @@ module Edgeway
110
121
  def size = to_a.size
111
122
  def empty? = !exists?
112
123
 
113
- def find_by(conditions) = Relation.new(@model, @where_cols, @binds, @order, @limit, nil, @eager).where(conditions).limit(1).to_a.first
124
+ def find_by(conditions) = Relation.new(@model, @conds, @binds, @eq_cols, @eq_vals, @order, @limit, nil, @eager).where(conditions).limit(1).to_a.first
114
125
 
115
126
  def find(id)
116
127
  find_by("id" => id) || raise(RecordNotFound, "#{@model.table_name} #{id} not found")
@@ -118,7 +129,7 @@ module Edgeway
118
129
 
119
130
  def build(attrs = {})
120
131
  record = @model.new(attrs)
121
- @where_cols.each_with_index { |col, i| record.write_attribute(col, @binds[i]) }
132
+ @eq_cols.each_with_index { |col, i| record.write_attribute(col, @eq_vals[i]) }
122
133
  record
123
134
  end
124
135
 
@@ -154,6 +165,7 @@ module Edgeway
154
165
  @eager.each_with_index do |name, i|
155
166
  many_flags << eager_many?(name)
156
167
  other = Edgeway::Model::Base.model_named(name, @model.name)
168
+ check_same_database(name, other)
157
169
  theirs = "eager_#{name}"
158
170
  joins << " LEFT JOIN #{other.table_name} AS #{theirs} ON #{eager_on(name, other, theirs)}"
159
171
  cols = cols + other.columns.map { |c| "#{theirs}.#{c} AS c#{i}_#{c}" }
@@ -168,7 +180,7 @@ module Edgeway
168
180
  children = []
169
181
  position = {}
170
182
  seen = {}
171
- Model.connection.select_all(sql, binds).each do |row|
183
+ @model.connection.select_all(sql, binds).each do |row|
172
184
  id = row["p_id"]
173
185
  i = position[id]
174
186
  if i.nil?
@@ -205,6 +217,11 @@ module Edgeway
205
217
  records
206
218
  end
207
219
 
220
+ def check_same_database(name, other)
221
+ return if other.database == @model.database
222
+ raise ArgumentError, "eager_load :#{name} joins #{other.name} (database #{other.database}) to #{@model.name} (database #{@model.database}); a JOIN can't cross databases"
223
+ end
224
+
208
225
  def eager_many?(name)
209
226
  many = nil
210
227
  @model.has_manys.each { |h| many = h if h.name == name }
@@ -223,9 +240,7 @@ module Edgeway
223
240
  if many.nil?
224
241
  one = nil
225
242
  @model.belongs_to_list.each { |b| one = b if b.name == name }
226
- # .dup: since matz/spinel 44119ab30 (Strings shared by reference),
227
- # Spinel refuses this String as it is, or emits C that doesn't compile.
228
- return "#{theirs}.#{other.column(one.primary_key.dup)} = #{mine}.#{@model.column(one.foreign_key.dup)}"
243
+ return "#{theirs}.#{other.column(one.primary_key)} = #{mine}.#{@model.column(one.foreign_key)}"
229
244
  end
230
245
  fk = many.foreign_key.nil? ? "#{Model.underscore(@model.name.split("::").last)}_id" : many.foreign_key
231
246
  pk = many.primary_key.nil? ? "id" : many.primary_key
@@ -256,39 +271,74 @@ module Edgeway
256
271
  "#{@model.column(col)} #{d.upcase}"
257
272
  end
258
273
 
259
- # " WHERE ..." (empty without conditions) and the number of placeholders
260
- # it used. A nil value is IS NULL, with no bind.
261
- def where_clause(conn)
262
- marks = 0
263
- return ["", marks] if @where_cols.empty?
264
- conds = @where_cols.each_with_index.map do |c, i|
265
- if @binds[i].nil?
266
- "#{c} IS NULL"
267
- else
268
- marks += 1
269
- "#{c} = #{conn.placeholder(marks)}"
270
- end
274
+ def add_fragment(sql, values, conds, binds)
275
+ conds << "(#{sql})"
276
+ values.each do |v|
277
+ raise ArgumentError, "an Array bind in a SQL fragment isn't expanded; use where(column: [...])" if v.is_a?(Array)
278
+ binds << Model.database_value(v)
279
+ end
280
+ end
281
+
282
+ def add_in(column, type, values, conds, binds)
283
+ vals = values.map { |x| bind_value(type, x) }
284
+ rest = vals.compact
285
+ has_nil = rest.size < vals.size
286
+ if rest.empty?
287
+ conds << (has_nil ? "#{column} IS NULL" : "1 = 0")
288
+ return
289
+ end
290
+ term = "#{column} IN (#{rest.map { |_x| "?" }.join(", ")})"
291
+ conds << (has_nil ? "(#{term} OR #{column} IS NULL)" : term)
292
+ rest.each { |x| binds << x }
293
+ end
294
+
295
+ def add_range(column, type, range, conds, binds)
296
+ lo = range.begin
297
+ hi = range.end
298
+ unless lo.nil?
299
+ conds << "#{column} >= ?"
300
+ binds << bind_value(type, lo)
301
+ end
302
+ unless hi.nil?
303
+ conds << "#{column} #{range.exclude_end? ? "<" : "<="} ?"
304
+ binds << bind_value(type, hi)
305
+ end
306
+ end
307
+
308
+ def add_equality(column, type, value, conds, binds, eq_cols, eq_vals)
309
+ bound = bind_value(type, value)
310
+ if bound.nil?
311
+ conds << "#{column} IS NULL"
312
+ else
313
+ conds << "#{column} = ?"
314
+ binds << bound
271
315
  end
272
- [" WHERE " + conds.join(" AND "), marks]
316
+ eq_cols << column
317
+ eq_vals << bound
318
+ end
319
+
320
+ # The value as it is bound. An integer outside 32 bits can't be held as
321
+ # one; it goes as its text, which SQLite compares as the number it is
322
+ # (an equality on it matches nothing: a find on it is a 404).
323
+ def bind_value(type, v)
324
+ v = Model.database_value(v) if v.is_a?(Time)
325
+ casted = Model.cast(type, v)
326
+ casted = v.to_s if casted.nil? && !v.nil?
327
+ Model.database_value(casted, type)
273
328
  end
274
329
 
330
+ def where_clause = @conds.empty? ? "" : " WHERE " + @conds.join(" AND ")
331
+
275
332
  def select(what, ordered)
276
- conn = Model.connection
277
- where_sql, _marks = where_clause(conn)
278
- sql = "SELECT #{what} FROM #{@model.table_name}#{where_sql}"
333
+ sql = "SELECT #{what} FROM #{@model.table_name}#{where_clause}"
279
334
  sql += " ORDER BY #{@order.join(", ")}" if ordered && !@order.empty?
280
- binds = @binds.compact
281
335
  if @limit
282
- binds = binds + [@limit]
283
- sql += " LIMIT #{conn.placeholder(binds.size)}"
336
+ sql += " LIMIT #{@limit}"
284
337
  elsif @offset
285
338
  sql += " LIMIT -1"
286
339
  end
287
- if @offset
288
- binds = binds + [@offset]
289
- sql += " OFFSET #{conn.placeholder(binds.size)}"
290
- end
291
- [sql, binds]
340
+ sql += " OFFSET #{@offset}" if @offset
341
+ [sql, @binds]
292
342
  end
293
343
  end
294
344
  end