edgeway 0.1.0 → 0.3.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +784 -125
  4. data/js/cloudflare-d1-sessions.js +68 -0
  5. data/js/cloudflare-kv.js +47 -0
  6. data/js/cloudflare-ratelimit.js +31 -0
  7. data/js/cloudflare-test-database.js +112 -0
  8. data/js/cloudflare-test-runner.js +76 -0
  9. data/js/cloudflare.js +144 -22
  10. data/js/cors.js +15 -16
  11. data/js/fetch.js +113 -0
  12. data/js/headers.js +16 -0
  13. data/js/jspi.js +32 -0
  14. data/js/jwt.js +6 -40
  15. data/js/runtime.js +42 -5
  16. data/lib/edgeway/application.rb +21 -9
  17. data/lib/edgeway/cache/limits.rb +8 -0
  18. data/lib/edgeway/cache/memory_store.rb +24 -0
  19. data/lib/edgeway/cache/null_store.rb +13 -0
  20. data/lib/edgeway/cache/store.rb +189 -0
  21. data/lib/edgeway/cache.rb +13 -0
  22. data/lib/edgeway/cli/build/app_config.rb +39 -0
  23. data/lib/edgeway/cli/build/app_files.rb +40 -0
  24. data/lib/edgeway/cli/build/cache_config.rb +108 -0
  25. data/lib/edgeway/cli/build/database_config.rb +96 -0
  26. data/lib/edgeway/cli/build/js_files.rb +30 -0
  27. data/lib/edgeway/cli/build/view_compiler.rb +191 -0
  28. data/lib/edgeway/cli/build.rb +50 -18
  29. data/lib/edgeway/cli/cloudflare_test/wasm_test_cli.rb +35 -0
  30. data/lib/edgeway/cli/cloudflare_test.rb +120 -0
  31. data/lib/edgeway/cli/generate.rb +174 -26
  32. data/lib/edgeway/cli/new.rb +1 -1
  33. data/lib/edgeway/cli/test.rb +28 -9
  34. data/lib/edgeway/cli/toolchain.rb +5 -0
  35. data/lib/edgeway/cli.rb +7 -4
  36. data/lib/edgeway/controller/base.rb +32 -3
  37. data/lib/edgeway/controller/caching.rb +140 -0
  38. data/lib/edgeway/controller/callbacks.rb +5 -1
  39. data/lib/edgeway/controller/dispatcher.rb +11 -1
  40. data/lib/edgeway/controller/rate_limiting.rb +145 -0
  41. data/lib/edgeway/controller/rendering.rb +24 -4
  42. data/lib/edgeway/db/adapter.rb +38 -0
  43. data/lib/edgeway/db/memory.rb +9 -2
  44. data/lib/edgeway/db/query_stats.rb +42 -0
  45. data/lib/edgeway/db/sqlite.rb +73 -9
  46. data/lib/edgeway/errors.rb +43 -0
  47. data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +38 -0
  48. data/lib/edgeway/generators/app/templates/.ruby-version.tt +1 -0
  49. data/lib/edgeway/generators/app/templates/README.md.tt +8 -2
  50. data/lib/edgeway/generators/app/templates/app/views/layouts/application.html.erb +11 -0
  51. data/lib/edgeway/generators/app/templates/bin/build.tt +1 -1
  52. data/lib/edgeway/generators/app/templates/bin/deploy.tt +1 -1
  53. data/lib/edgeway/generators/app/templates/bin/setup.tt +2 -3
  54. data/lib/edgeway/generators/app/templates/bin/test.tt +7 -0
  55. data/lib/edgeway/generators/app/templates/config/cache.yml +22 -0
  56. data/lib/edgeway/generators/app/templates/config/database.yml +25 -2
  57. data/lib/edgeway/generators/app/templates/mise.toml.tt +1 -1
  58. data/lib/edgeway/generators/app/templates/public/.assetsignore +1 -0
  59. data/lib/edgeway/generators/app/templates/wrangler.toml.tt +39 -0
  60. data/lib/edgeway/http/auth/stub.rb +1 -3
  61. data/lib/edgeway/http/client.rb +80 -0
  62. data/lib/edgeway/http/request.rb +7 -22
  63. data/lib/edgeway/http/stub_transport.rb +38 -0
  64. data/lib/edgeway/inflector.rb +19 -0
  65. data/lib/edgeway/logger.rb +33 -5
  66. data/lib/edgeway/model/attributes.rb +93 -10
  67. data/lib/edgeway/model/base.rb +50 -5
  68. data/lib/edgeway/model/connection_registry.rb +25 -0
  69. data/lib/edgeway/model/macros.rb +5 -1
  70. data/lib/edgeway/model/persistence.rb +18 -11
  71. data/lib/edgeway/model/relation.rb +106 -56
  72. data/lib/edgeway/model/validations.rb +29 -23
  73. data/lib/edgeway/rate_limit/limiter.rb +9 -0
  74. data/lib/edgeway/rate_limit/null_limiter.rb +9 -0
  75. data/lib/edgeway/rate_limit/stub_limiter.rb +24 -0
  76. data/lib/edgeway/rate_limit.rb +2 -0
  77. data/lib/edgeway/routing/route.rb +1 -6
  78. data/lib/edgeway/server.rb +13 -0
  79. data/lib/edgeway/service/cloudflare/d1.rb +44 -30
  80. data/lib/edgeway/service/cloudflare/jwt.rb +3 -15
  81. data/lib/edgeway/service/cloudflare/kv.rb +45 -0
  82. data/lib/edgeway/service/cloudflare/kv_store.rb +58 -0
  83. data/lib/edgeway/service/cloudflare/rate_limit.rb +27 -0
  84. data/lib/edgeway/service/cloudflare/rate_limiter.rb +33 -0
  85. data/lib/edgeway/service/cloudflare/test_databases.rb +65 -0
  86. data/lib/edgeway/service/fetch.rb +26 -0
  87. data/lib/edgeway/service/import_result.rb +24 -0
  88. data/lib/edgeway/test/sqlite_databases.rb +18 -0
  89. data/lib/edgeway/test.rb +86 -26
  90. data/lib/edgeway/version.rb +1 -1
  91. data/lib/edgeway/view.rb +24 -0
  92. data/lib/edgeway.rb +9 -0
  93. metadata +50 -4
  94. data/lib/edgeway/cli/app_config.rb +0 -35
  95. data/lib/edgeway/cli/app_files.rb +0 -37
@@ -0,0 +1,140 @@
1
+ module Edgeway
2
+ module Controller
3
+ # HTTP caching helpers for actions:
4
+ #
5
+ # def show
6
+ # todo = Todo.find(params["id"])
7
+ # render json: todo if stale?(etag: todo, last_modified: todo.updated_at)
8
+ # end
9
+ #
10
+ # Only the validators an action names are used; nothing is derived from
11
+ # the response body.
12
+ module Caching
13
+ ETAG_LENGTH = 32
14
+
15
+ def expires_in(seconds, public: false, must_revalidate: false)
16
+ raise ArgumentError, "seconds must be a non-negative Integer" unless Caching.duration?(seconds)
17
+ value = "max-age=#{seconds}, #{public ? "public" : "private"}"
18
+ value += ", must-revalidate" if must_revalidate
19
+ @headers["Cache-Control"] = value
20
+ nil
21
+ end
22
+
23
+ def no_store
24
+ @headers["Cache-Control"] = "no-store"
25
+ nil
26
+ end
27
+
28
+ # Returns true when it answered 304 (the action is done), false
29
+ # otherwise.
30
+ def fresh_when(etag: nil, last_modified: nil, public: false)
31
+ etag_value = etag.nil? ? nil : Caching.etag_for(etag)
32
+ modified_time = Caching.time_for(last_modified)
33
+ @headers["ETag"] = etag_value unless etag_value.nil?
34
+ @headers["Last-Modified"] = modified_time.httpdate unless modified_time.nil?
35
+ set_default_cache_control(public)
36
+ return false unless cacheable_request? && request_fresh?(etag_value, modified_time)
37
+ remove_representation_headers
38
+ head :not_modified
39
+ true
40
+ end
41
+
42
+ # fresh_when, answering true when the action should go on to render.
43
+ def stale?(etag: nil, last_modified: nil, public: false)
44
+ !fresh_when(etag: etag, last_modified: last_modified, public: public)
45
+ end
46
+
47
+ # Integer() gives Spinel a type for a parameter no call site types.
48
+ def self.duration?(seconds)
49
+ seconds.is_a?(Integer) && Integer(seconds) >= 0
50
+ end
51
+
52
+ # Weak, because a proxy may recompress the body and weaken a strong
53
+ # validator on the way.
54
+ def self.etag_for(source)
55
+ digest = Digest::SHA256.hexdigest(etag_source(source))
56
+ "W/\"#{digest[0, ETAG_LENGTH]}\""
57
+ end
58
+
59
+ def self.etag_source(source)
60
+ if source.is_a?(Model::Base)
61
+ model_source(source)
62
+ elsif source.is_a?(Model::Relation)
63
+ list_source(source.to_a)
64
+ elsif source.is_a?(Array)
65
+ list_source(source)
66
+ else
67
+ source.to_s
68
+ end
69
+ end
70
+
71
+ def self.model_source(model)
72
+ updated_at = model.as_json["updated_at"].to_s
73
+ raise ArgumentError, "#{model.class.name} has no updated_at (add timestamps)" if updated_at.empty?
74
+ "#{model.class.name}/#{model.id}/#{updated_at}"
75
+ end
76
+
77
+ def self.list_source(items)
78
+ "[" + items.map { |item| etag_source(item) }.join(",") + "]"
79
+ end
80
+
81
+ def self.time_for(value)
82
+ return nil if value.nil?
83
+ return value.getutc if value.is_a?(Time)
84
+ raise ArgumentError, "last_modified must be a Time or an ISO 8601 String" unless value.is_a?(String)
85
+ begin
86
+ Time.iso8601(value).utc
87
+ rescue ArgumentError
88
+ raise ArgumentError, "last_modified is not an ISO 8601 String: #{value.inspect}"
89
+ end
90
+ end
91
+
92
+ # The validators match the ones the client sent (RFC 9110 13.1.2,
93
+ # 13.1.3): If-None-Match decides when it is present, and
94
+ # If-Modified-Since is then ignored.
95
+ def self.fresh?(none_match, modified_since, etag, last_modified)
96
+ return etag_listed?(none_match, etag) unless none_match.empty?
97
+ return false if modified_since.empty? || last_modified.nil?
98
+ since = parse_http_date(modified_since)
99
+ !since.nil? && last_modified.to_i <= since.to_i
100
+ end
101
+
102
+ def self.etag_listed?(header, etag)
103
+ return false if etag.nil?
104
+ candidates = header.split(",").map { |candidate| candidate.strip }
105
+ return true if candidates.include?("*")
106
+ candidates.any? { |candidate| candidate.delete_prefix("W/") == etag.delete_prefix("W/") }
107
+ end
108
+
109
+ def self.parse_http_date(text)
110
+ Time.httpdate(text).utc
111
+ rescue ArgumentError
112
+ nil
113
+ end
114
+
115
+ private
116
+
117
+ def set_default_cache_control(public)
118
+ return if header_set?("Cache-Control")
119
+ @headers["Cache-Control"] = "max-age=0, #{public ? "public" : "private"}, must-revalidate"
120
+ end
121
+
122
+ def header_set?(name)
123
+ @headers.keys.any? { |key| key.downcase == name.downcase }
124
+ end
125
+
126
+ def remove_representation_headers
127
+ @headers.delete("Content-Type")
128
+ @headers.delete("X-Content-Type-Options")
129
+ end
130
+
131
+ def cacheable_request?
132
+ @request.method == "GET" || @request.method == "HEAD"
133
+ end
134
+
135
+ def request_fresh?(etag_value, modified_time)
136
+ Caching.fresh?(@request.header("If-None-Match"), @request.header("If-Modified-Since"), etag_value, modified_time)
137
+ end
138
+ end
139
+ end
140
+ end
@@ -9,6 +9,10 @@ module Edgeway
9
9
  @except = except
10
10
  end
11
11
 
12
+ def run(controller)
13
+ controller.send(@name)
14
+ end
15
+
12
16
  def applies?(action)
13
17
  return false if @except.include?(action)
14
18
  @only.empty? || @only.include?(action)
@@ -19,7 +23,7 @@ module Edgeway
19
23
  def run_before_actions(action)
20
24
  self.class.all_before_actions.each do |callback|
21
25
  next unless callback.applies?(action)
22
- send(callback.name)
26
+ callback.run(self)
23
27
  return false if performed?
24
28
  end
25
29
  true
@@ -15,6 +15,7 @@ module Edgeway
15
15
  klass = controller_class(route)
16
16
  raise RoutingError, "no controller for #{route.controller_path}" if klass.nil?
17
17
  controller = klass.new(request)
18
+ controller.action_name = route.action
18
19
  # A route whose action isn't defined (or is one of Base's own public
19
20
  # methods) is a 404, as in Rails.
20
21
  raise RoutingError, "no route for #{request.method} #{request.path}" unless controller.action?(route.action)
@@ -26,7 +27,7 @@ module Edgeway
26
27
  # a route can't reach a private method.
27
28
  action = route.action.to_sym
28
29
  controller.public_send(action) if controller.run_before_actions(route.action)
29
- controller.head(:no_content) unless controller.performed?
30
+ implicit_render(controller, route) unless controller.performed?
30
31
  rescue => e
31
32
  raise e unless controller.rescue_with_handler(e)
32
33
  # A handler that rendered nothing answers 204, as an action does.
@@ -34,6 +35,15 @@ module Edgeway
34
35
  end
35
36
  controller.to_response
36
37
  end
38
+
39
+ def self.implicit_render(controller, route)
40
+ content = controller._render_view(route.action)
41
+ if content.nil?
42
+ controller.head(:no_content)
43
+ else
44
+ controller.render(html: View::SafeHTML.new(content))
45
+ end
46
+ end
37
47
  end
38
48
  end
39
49
  end
@@ -0,0 +1,145 @@
1
+ module Edgeway
2
+ module Controller
3
+ @rate_limiter = RateLimit::NullLimiter.new
4
+
5
+ def self.rate_limiter = @rate_limiter
6
+
7
+ def self.rate_limiter=(limiter)
8
+ @rate_limiter = limiter
9
+ end
10
+
11
+ # Rate limiting through a limiter binding:
12
+ #
13
+ # class PostsController < ApplicationController
14
+ # rate_limit "WRITE_LIMITER", only: [:create], retry_after: 10
15
+ # end
16
+ #
17
+ # It is a before_action. Over the limit it raises TooManyRequests (429).
18
+ # The limit and the period are set on the binding, not here. A by: method is
19
+ # never a route's action (Base.rate_limit adds it to the non-actions).
20
+ module RateLimiting
21
+ class Rule < Callback
22
+ attr_reader :binding_name, :by, :scope_name, :retry_after
23
+
24
+ def self.build(binding_name, by, name, retry_after, only, except)
25
+ raise ArgumentError, "the limiter binding name must be a non-empty String" unless binding_name.is_a?(String) && !binding_name.empty?
26
+ raise ArgumentError, "by: must be a Symbol" unless by.nil? || by.is_a?(Symbol)
27
+ raise ArgumentError, "name: must be a String" unless name.nil? || name.is_a?(String)
28
+ raise ArgumentError, "retry_after: must be a positive Integer" unless retry_after.nil? || RateLimiting.positive_integer?(retry_after)
29
+ new(binding_name, by, name, retry_after.nil? ? 0 : retry_after, only.map(&:to_s), except.map(&:to_s))
30
+ end
31
+
32
+ def initialize(binding_name, by, name, retry_after, only, except)
33
+ super("rate_limit", only, except)
34
+ @binding_name = binding_name
35
+ @by = by
36
+ @scope_name = name
37
+ @retry_after = retry_after
38
+ end
39
+
40
+ def run(controller)
41
+ controller.send(:enforce_rate_limit, self)
42
+ end
43
+ end
44
+
45
+ IPV6_GROUPS = 8
46
+ IPV6_PREFIX_GROUPS = 4
47
+ IPV4_OCTETS = 4
48
+ IPV4_MAPPED_ZERO_GROUPS = 5
49
+ IPV4_MAPPED_MARKER_GROUP = 5
50
+ IPV4_HIGH_GROUP = 6
51
+ IPV4_LOW_GROUP = 7
52
+ HEX_DIGITS = "0123456789abcdef"
53
+
54
+ # An IPv6 client usually has a whole /64, so its address is keyed by
55
+ # that prefix; an IPv4 address (or one that does not parse) is kept.
56
+ def self.address_key(address)
57
+ return address unless address.include?(":")
58
+
59
+ text = address.downcase.split("%")[0].to_s
60
+ groups = ipv6_groups(embedded_ipv4_as_hex(text))
61
+ return address if groups.nil?
62
+
63
+ ipv4_mapped?(groups) ? ipv4_from_groups(groups) : groups[0, IPV6_PREFIX_GROUPS].join(":") + "::/64"
64
+ end
65
+
66
+ # "::ffff:192.0.2.1" -> "::ffff:c000:201", so the dotted tail parses as two groups.
67
+ def self.embedded_ipv4_as_hex(text)
68
+ colon = text.rindex(":").to_i
69
+ octets = text[colon + 1, text.length].to_s.split(".", -1)
70
+ return text unless octets.length == IPV4_OCTETS && octets.all? { |octet| decimal_octet?(octet) }
71
+
72
+ values = octets.map { |octet| octet.to_i }
73
+ high = (values[0] * 256 + values[1]).to_s(16)
74
+ low = (values[2] * 256 + values[3]).to_s(16)
75
+ text[0, colon + 1].to_s + high + ":" + low
76
+ end
77
+
78
+ def self.decimal_octet?(octet)
79
+ octet.length >= 1 && octet.length <= 3 && octet.each_char.all? { |digit| "0123456789".include?(digit) } && octet.to_i <= 255
80
+ end
81
+
82
+ def self.ipv4_mapped?(groups)
83
+ groups[0, IPV4_MAPPED_ZERO_GROUPS].all? { |group| group == "0" } && groups[IPV4_MAPPED_MARKER_GROUP] == "ffff"
84
+ end
85
+
86
+ def self.ipv4_from_groups(groups)
87
+ high = groups[IPV4_HIGH_GROUP].to_i(16)
88
+ low = groups[IPV4_LOW_GROUP].to_i(16)
89
+ [high / 256, high % 256, low / 256, low % 256].join(".")
90
+ end
91
+
92
+ def self.ipv6_groups(text)
93
+ halves = text.split("::", -1)
94
+ return nil if halves.empty? || halves.length > 2
95
+
96
+ head = group_list(halves[0])
97
+ tail = halves.length == 2 ? group_list(halves[1]) : []
98
+ return nil if head.nil? || tail.nil?
99
+
100
+ filler = IPV6_GROUPS - head.length - tail.length
101
+ return nil if halves.length == 1 ? filler != 0 : filler < 1
102
+
103
+ head + Array.new(filler, "0") + tail
104
+ end
105
+
106
+ def self.group_list(text)
107
+ return [] if text.empty?
108
+
109
+ groups = text.split(":", -1).map { |group| normalized_group(group) }
110
+ groups.include?("") ? nil : groups
111
+ end
112
+
113
+ def self.normalized_group(group)
114
+ return "" if group.empty? || group.length > 4 || !group.each_char.all? { |digit| HEX_DIGITS.include?(digit) }
115
+
116
+ group.to_i(16).to_s(16)
117
+ end
118
+
119
+ # Integer() gives Spinel a type for a parameter no call site types.
120
+ def self.positive_integer?(value)
121
+ value.is_a?(Integer) && Integer(value) > 0
122
+ end
123
+
124
+ private
125
+
126
+ def enforce_rate_limit(rule)
127
+ return unless Edgeway::Controller.rate_limiter.exceeded?(rule.binding_name, rate_limit_key_for(rule))
128
+
129
+ raise TooManyRequests.new("rate limit exceeded (#{rule.binding_name})", rule.retry_after)
130
+ end
131
+
132
+ def rate_limit_key_for(rule)
133
+ scope = rule.scope_name.nil? ? self.class.name : rule.scope_name
134
+ "#{scope}:#{rule.by.nil? ? default_rate_limit_key : send(rule.by).to_s}"
135
+ end
136
+
137
+ def default_rate_limit_key
138
+ subject = authenticated? ? current_claims["sub"].to_s : ""
139
+ return "user:#{subject}" unless subject.empty?
140
+
141
+ "ip:#{RateLimiting.address_key(request.remote_addr)}"
142
+ end
143
+ end
144
+ end
145
+ end
@@ -10,6 +10,8 @@ module Edgeway
10
10
  "service_unavailable" => 503
11
11
  }
12
12
 
13
+ UNSET = Object.new
14
+
13
15
  def self.json_response(status, json_text)
14
16
  HTTP::Response.json(status, json_text)
15
17
  end
@@ -39,10 +41,28 @@ module Edgeway
39
41
  end
40
42
  end
41
43
 
42
- def render(json:, status: 200)
43
- @status = Rendering.status_code(status)
44
- @headers["Content-Type"] = "application/json"
45
- @response_body = JSON.generate(Rendering.json_value(json)) + "\n"
44
+ def self.html_content(value)
45
+ value.is_a?(View::SafeHTML) ? value.to_s : View.escape(value)
46
+ end
47
+
48
+ def render(template = nil, json: UNSET, html: UNSET, status: 200, layout: true)
49
+ code = Rendering.status_code(status)
50
+ if !json.equal?(UNSET)
51
+ body = JSON.generate(Rendering.json_value(json)) + "\n"
52
+ @headers["Content-Type"] = "application/json"
53
+ else
54
+ content = html.equal?(UNSET) ? nil : Rendering.html_content(html)
55
+ if content.nil?
56
+ name = template.nil? ? @action_name : template.to_s
57
+ content = _render_view(name)
58
+ raise MissingTemplate, "no template #{name.inspect} for #{self.class.name}" if content.nil?
59
+ end
60
+ body = layout ? _render_layout(content) : content
61
+ @headers["Content-Type"] = "text/html; charset=utf-8"
62
+ @headers["X-Content-Type-Options"] = "nosniff"
63
+ end
64
+ @status = code
65
+ @response_body = body
46
66
  @performed = true
47
67
  end
48
68
 
@@ -1,6 +1,18 @@
1
+ require_relative "query_stats"
2
+
1
3
  module Edgeway
2
4
  module DB
3
5
  class Adapter
6
+ MAX_BINDS = 100
7
+
8
+ def query_stats
9
+ @query_stats ||= QueryStats.new
10
+ end
11
+
12
+ def record_meta(meta)
13
+ query_stats.add(meta)
14
+ end
15
+
4
16
  def select_all(sql, binds)
5
17
  raise Error, "#{self.class} does not define select_all"
6
18
  end
@@ -9,6 +21,10 @@ module Edgeway
9
21
  raise Error, "#{self.class} does not define execute"
10
22
  end
11
23
 
24
+ def exec(sql)
25
+ raise Error, "#{self.class} does not define exec"
26
+ end
27
+
12
28
  def batch(statements)
13
29
  raise Error, "#{self.class} does not define batch"
14
30
  end
@@ -19,11 +35,33 @@ module Edgeway
19
35
  rows[0]
20
36
  end
21
37
 
38
+ def last_insert_id_of(value)
39
+ return 0 if value.nil?
40
+ number = value.to_i
41
+ number > Edgeway::Model::INT_MAX || number < Edgeway::Model::INT_MIN ? value.to_s : number
42
+ rescue RangeError
43
+ value.to_s
44
+ end
45
+
46
+ def check_binds(binds)
47
+ return if binds.size <= MAX_BINDS
48
+ raise ArgumentError, "too many bind parameters: #{binds.size} (at most #{MAX_BINDS})"
49
+ end
50
+
22
51
  def fail_with(message)
23
52
  raise RecordNotUnique, message if message.include?("UNIQUE constraint failed")
53
+ raise NotNullViolation, message if message.include?("NOT NULL constraint failed")
54
+ raise InvalidForeignKey, message if message.include?("FOREIGN KEY constraint failed")
55
+ raise CheckViolation, message if message.include?("CHECK constraint failed")
24
56
  raise DatabaseError, message
25
57
  end
26
58
 
59
+ def raise_error_result(res, prefix = "")
60
+ message = "#{prefix}#{res["error"]}"
61
+ raise DatabaseUnavailable, message if res.key?("unavailable")
62
+ fail_with(message)
63
+ end
64
+
27
65
  def supports_returning? = true
28
66
  def placeholder(n) = "?"
29
67
  def quote_boolean(value) = value ? 1 : 0
@@ -32,8 +32,8 @@ module Edgeway
32
32
  end
33
33
 
34
34
  # Makes the next query or execute whose SQL contains `containing` (any,
35
- # by default) fail with this database message, after logging it, as D1
36
- # would: "UNIQUE constraint failed: t.c" is a RecordNotUnique.
35
+ # by default) fail with this database message, after logging it, as the
36
+ # database would: "UNIQUE constraint failed: t.c" is a RecordNotUnique.
37
37
  def fail_next(message, containing = "")
38
38
  @failure = message
39
39
  @failure_on = containing
@@ -42,18 +42,25 @@ module Edgeway
42
42
  def supports_returning? = @returning
43
43
 
44
44
  def select_all(sql, binds)
45
+ check_binds(binds)
45
46
  record(sql, binds)
46
47
  @queue.empty? ? [] : @queue.shift
47
48
  end
48
49
 
49
50
  def execute(sql, binds)
51
+ check_binds(binds)
50
52
  record(sql, binds)
51
53
  id = @next_id
52
54
  @next_id += 1
53
55
  { "changes" => @changes, "last_insert_id" => id }
54
56
  end
55
57
 
58
+ def exec(sql)
59
+ record(sql, [])
60
+ end
61
+
56
62
  def batch(statements)
63
+ statements.each { |s| check_binds(s[1]) }
57
64
  @log << "BATCH #{statements.size}"
58
65
  statements.map { |s| execute(s[0], s[1]) }
59
66
  end
@@ -0,0 +1,42 @@
1
+ module Edgeway
2
+ module DB
3
+ # What the database reported for one request.
4
+ class QueryStats
5
+ MAX_COUNT = 2147483647
6
+
7
+ attr_reader :queries, :rows_read, :rows_written, :duration_ms, :served_by_region, :served_by_primary
8
+
9
+ def initialize
10
+ @queries = 0
11
+ @rows_read = 0
12
+ @rows_written = 0
13
+ @duration_ms = 0.0
14
+ @served_by_region = ""
15
+ @served_by_primary = true
16
+ end
17
+
18
+ def add(meta)
19
+ rows_read = capped_sum(@rows_read, meta["rows_read"])
20
+ rows_written = capped_sum(@rows_written, meta["rows_written"])
21
+ duration_ms = @duration_ms + meta["duration"].to_f
22
+ @rows_read = rows_read
23
+ @rows_written = rows_written
24
+ @duration_ms = duration_ms
25
+ @served_by_region = meta["served_by_region"].to_s if meta.key?("served_by_region")
26
+ @served_by_primary = meta["served_by_primary"] != false if meta.key?("served_by_primary")
27
+ @queries = capped_sum(@queries, 1)
28
+ end
29
+
30
+ def log_fields
31
+ { "queries" => @queries, "rows_read" => @rows_read, "rows_written" => @rows_written, "db_ms" => @duration_ms.round(2) }
32
+ end
33
+
34
+ private
35
+
36
+ def capped_sum(total, count)
37
+ return MAX_COUNT if count.to_i > MAX_COUNT - total
38
+ total + count.to_i
39
+ end
40
+ end
41
+ end
42
+ end
@@ -20,6 +20,7 @@ module Edgeway
20
20
  ffi_func :sqlite3_prepare_v2, [:ptr, :str, :int, :ptr, :ptr], :int
21
21
  ffi_func :sqlite3_bind_text, [:ptr, :int, :str, :int, :ptr], :int
22
22
  ffi_func :sqlite3_bind_int64, [:ptr, :int, :long], :int
23
+ ffi_func :sqlite3_bind_blob, [:ptr, :int, :str, :int, :ptr], :int
23
24
  ffi_func :sqlite3_bind_double, [:ptr, :int, :double], :int
24
25
  ffi_func :sqlite3_bind_null, [:ptr, :int], :int
25
26
  ffi_func :sqlite3_step, [:ptr], :int
@@ -35,6 +36,35 @@ module Edgeway
35
36
  ffi_func :sqlite3_changes, [:ptr], :int
36
37
  ffi_func :sqlite3_libversion_number, [], :int
37
38
 
39
+ # A BLOB can hold NUL, which would cut a :str result short, so it is read
40
+ # as hex text.
41
+ ffi_source <<~C
42
+ #include <stdlib.h>
43
+
44
+ extern const void *sqlite3_column_blob(void *statement, int column);
45
+ extern int sqlite3_column_bytes(void *statement, int column);
46
+
47
+ static char *hex;
48
+ static int hex_capacity;
49
+
50
+ const char *column_hex(void *statement, int column) {
51
+ const unsigned char *bytes = sqlite3_column_blob(statement, column);
52
+ int length = sqlite3_column_bytes(statement, column);
53
+ if (2 * length + 1 > hex_capacity) {
54
+ hex = realloc(hex, 2 * length + 1);
55
+ if (!hex) abort();
56
+ hex_capacity = 2 * length + 1;
57
+ }
58
+ for (int i = 0; i < length; i++) {
59
+ hex[2 * i] = "0123456789abcdef"[bytes[i] >> 4];
60
+ hex[2 * i + 1] = "0123456789abcdef"[bytes[i] & 15];
61
+ }
62
+ hex[2 * length] = 0;
63
+ return hex;
64
+ }
65
+ C
66
+ ffi_func :column_hex, [:ptr, :int], :str
67
+
38
68
  ffi_buffer :db_out, 8
39
69
  ffi_buffer :stmt_out, 8
40
70
  ffi_read_ptr :read_ptr, 0
@@ -43,6 +73,7 @@ module Edgeway
43
73
  class SQLiteAdapter < Adapter
44
74
  INTEGER = 1
45
75
  FLOAT = 2
76
+ BLOB = 4
46
77
  NULL = 5
47
78
 
48
79
  attr_reader :log
@@ -63,6 +94,7 @@ module Edgeway
63
94
  def supports_returning? = @returning
64
95
 
65
96
  def select_all(sql, binds)
97
+ check_binds(binds)
66
98
  stmt = start(sql, binds)
67
99
  rows = []
68
100
  rc = SQLiteFFI.sqlite3_step(stmt)
@@ -75,26 +107,25 @@ module Edgeway
75
107
  end
76
108
 
77
109
  def execute(sql, binds)
110
+ check_binds(binds)
78
111
  stmt = start(sql, binds)
79
112
  rc = SQLiteFFI.sqlite3_step(stmt)
80
113
  while rc == SQLiteFFI::ROW
81
114
  rc = SQLiteFFI.sqlite3_step(stmt)
82
115
  end
83
116
  finish(stmt, rc)
84
- { "changes" => SQLiteFFI.sqlite3_changes(@db), "last_insert_id" => SQLiteFFI.sqlite3_last_insert_rowid(@db) }
117
+ { "changes" => SQLiteFFI.sqlite3_changes(@db), "last_insert_id" => last_insert_id_of(SQLiteFFI.sqlite3_last_insert_rowid(@db)) }
118
+ end
119
+
120
+ def exec(sql)
121
+ @log << "#{sql} | []"
122
+ in_transaction { sql.split("\n").each { |line| run_script(line) unless line.strip.empty? } }
85
123
  end
86
124
 
87
125
  def batch(statements)
88
126
  @log << "BATCH #{statements.size}"
89
- run_script("BEGIN")
90
127
  results = []
91
- begin
92
- statements.each { |s| results << execute(s[0], s[1]) }
93
- rescue => e
94
- run_script("ROLLBACK")
95
- raise e
96
- end
97
- run_script("COMMIT")
128
+ in_transaction { statements.each { |s| results << execute(s[0], s[1]) } }
98
129
  results
99
130
  end
100
131
 
@@ -113,6 +144,17 @@ module Edgeway
113
144
 
114
145
  private
115
146
 
147
+ def in_transaction
148
+ run_script("BEGIN")
149
+ begin
150
+ yield
151
+ rescue => e
152
+ run_script("ROLLBACK")
153
+ raise e
154
+ end
155
+ run_script("COMMIT")
156
+ end
157
+
116
158
  def run_script(sql_text)
117
159
  rc = SQLiteFFI.sqlite3_exec(@db, sql_text, nil, nil, nil)
118
160
  fail_with(SQLiteFFI.sqlite3_errmsg(@db)) if rc != SQLiteFFI::OK
@@ -122,6 +164,7 @@ module Edgeway
122
164
  # finalized before the error is raised.
123
165
  def start(sql, binds)
124
166
  @log << "#{sql} | #{JSON.generate(binds)}"
167
+ check_blob_binds(binds)
125
168
  rc = SQLiteFFI.sqlite3_prepare_v2(@db, sql, -1, SQLiteFFI.stmt_out, nil)
126
169
  if rc != SQLiteFFI::OK
127
170
  message = SQLiteFFI.sqlite3_errmsg(@db)
@@ -148,6 +191,8 @@ module Edgeway
148
191
  SQLiteFFI.sqlite3_bind_int64(stmt, n, value)
149
192
  elsif value.is_a?(Float)
150
193
  SQLiteFFI.sqlite3_bind_double(stmt, n, value)
194
+ elsif value.is_a?(Hash)
195
+ bind_blob(stmt, n, value)
151
196
  elsif value == true
152
197
  SQLiteFFI.sqlite3_bind_int64(stmt, n, 1)
153
198
  elsif value == false
@@ -158,6 +203,19 @@ module Edgeway
158
203
  end
159
204
  end
160
205
 
206
+ def check_blob_binds(binds)
207
+ binds.each do |value|
208
+ next unless value.is_a?(Hash)
209
+ bytes = value.key?("$blob") ? Edgeway::Model.cast(:binary, value) : nil
210
+ raise ArgumentError, "a Hash bind must be {\"$blob\" => base64}: #{value}" if bytes.nil?
211
+ end
212
+ end
213
+
214
+ def bind_blob(statement, position, value)
215
+ bytes = Edgeway::Model.cast(:binary, value).to_s
216
+ SQLiteFFI.sqlite3_bind_blob(statement, position, bytes, bytes.bytesize, -1)
217
+ end
218
+
161
219
  # Finalizes the statement; raises if its last step failed.
162
220
  def finish(stmt, rc)
163
221
  message = rc == SQLiteFFI::DONE ? "" : SQLiteFFI.sqlite3_errmsg(@db)
@@ -165,6 +223,10 @@ module Edgeway
165
223
  fail_with(message) if rc != SQLiteFFI::DONE
166
224
  end
167
225
 
226
+ def read_blob(statement, column)
227
+ Edgeway::Model.blob([SQLiteFFI.column_hex(statement, column)].pack("H*"))
228
+ end
229
+
168
230
  def read_row(stmt)
169
231
  row = {}
170
232
  n = SQLiteFFI.sqlite3_column_count(stmt)
@@ -178,6 +240,8 @@ module Edgeway
178
240
  row[name] = SQLiteFFI.sqlite3_column_int64(stmt, i)
179
241
  elsif type == FLOAT
180
242
  row[name] = SQLiteFFI.sqlite3_column_double(stmt, i)
243
+ elsif type == BLOB
244
+ row[name] = read_blob(stmt, i)
181
245
  else
182
246
  row[name] = SQLiteFFI.sqlite3_column_text(stmt, i)
183
247
  end