edgeway 0.1.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 (75) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +444 -0
  4. data/exe/edgeway +6 -0
  5. data/js/cgi.js +157 -0
  6. data/js/cloudflare.js +103 -0
  7. data/js/cors.js +87 -0
  8. data/js/jwt.js +279 -0
  9. data/js/runtime.js +99 -0
  10. data/js/wasi-shim.js +191 -0
  11. data/lib/edgeway/application.rb +70 -0
  12. data/lib/edgeway/cli/app_config.rb +35 -0
  13. data/lib/edgeway/cli/app_files.rb +37 -0
  14. data/lib/edgeway/cli/build.rb +90 -0
  15. data/lib/edgeway/cli/generate.rb +160 -0
  16. data/lib/edgeway/cli/new.rb +43 -0
  17. data/lib/edgeway/cli/package_manager.rb +21 -0
  18. data/lib/edgeway/cli/precheck.rb +45 -0
  19. data/lib/edgeway/cli/routes.rb +22 -0
  20. data/lib/edgeway/cli/test.rb +33 -0
  21. data/lib/edgeway/cli/toolchain.rb +35 -0
  22. data/lib/edgeway/cli.rb +59 -0
  23. data/lib/edgeway/config.rb +9 -0
  24. data/lib/edgeway/controller/authentication.rb +42 -0
  25. data/lib/edgeway/controller/base.rb +95 -0
  26. data/lib/edgeway/controller/callbacks.rb +29 -0
  27. data/lib/edgeway/controller/dispatcher.rb +39 -0
  28. data/lib/edgeway/controller/rendering.rb +65 -0
  29. data/lib/edgeway/controller/rescuable.rb +26 -0
  30. data/lib/edgeway/db/adapter.rb +32 -0
  31. data/lib/edgeway/db/memory.rb +73 -0
  32. data/lib/edgeway/db/sqlite.rb +190 -0
  33. data/lib/edgeway/errors.rb +61 -0
  34. data/lib/edgeway/generators/app/templates/.gitignore +6 -0
  35. data/lib/edgeway/generators/app/templates/Gemfile.tt +4 -0
  36. data/lib/edgeway/generators/app/templates/README.md.tt +22 -0
  37. data/lib/edgeway/generators/app/templates/app/controllers/application_controller.rb +9 -0
  38. data/lib/edgeway/generators/app/templates/app/controllers/health_controller.rb +3 -0
  39. data/lib/edgeway/generators/app/templates/app/models/application_model.rb +3 -0
  40. data/lib/edgeway/generators/app/templates/bin/build.tt +7 -0
  41. data/lib/edgeway/generators/app/templates/bin/deploy.tt +10 -0
  42. data/lib/edgeway/generators/app/templates/bin/setup.tt +38 -0
  43. data/lib/edgeway/generators/app/templates/config/database.yml +14 -0
  44. data/lib/edgeway/generators/app/templates/config/routes.rb +4 -0
  45. data/lib/edgeway/generators/app/templates/db/migrations/.keep +0 -0
  46. data/lib/edgeway/generators/app/templates/mise.toml.tt +24 -0
  47. data/lib/edgeway/generators/app/templates/package.json.tt +7 -0
  48. data/lib/edgeway/generators/app/templates/src/worker.js +4 -0
  49. data/lib/edgeway/generators/app/templates/test/controllers/health_test.rb +16 -0
  50. data/lib/edgeway/generators/app/templates/test/test_helper.rb +1 -0
  51. data/lib/edgeway/generators/app/templates/wrangler.toml.tt +23 -0
  52. data/lib/edgeway/http/auth/jwt.rb +127 -0
  53. data/lib/edgeway/http/auth/stub.rb +29 -0
  54. data/lib/edgeway/http/json_body.rb +37 -0
  55. data/lib/edgeway/http/parameters.rb +42 -0
  56. data/lib/edgeway/http/request.rb +115 -0
  57. data/lib/edgeway/http/response.rb +46 -0
  58. data/lib/edgeway/inflector.rb +16 -0
  59. data/lib/edgeway/logger.rb +21 -0
  60. data/lib/edgeway/model/attributes.rb +85 -0
  61. data/lib/edgeway/model/base.rb +173 -0
  62. data/lib/edgeway/model/macros.rb +60 -0
  63. data/lib/edgeway/model/persistence.rb +154 -0
  64. data/lib/edgeway/model/relation.rb +295 -0
  65. data/lib/edgeway/model/validations.rb +136 -0
  66. data/lib/edgeway/routing/mapper.rb +57 -0
  67. data/lib/edgeway/routing/route.rb +39 -0
  68. data/lib/edgeway/routing/route_set.rb +27 -0
  69. data/lib/edgeway/server.rb +12 -0
  70. data/lib/edgeway/service/cloudflare/d1.rb +101 -0
  71. data/lib/edgeway/service/cloudflare/jwt.rb +44 -0
  72. data/lib/edgeway/test.rb +97 -0
  73. data/lib/edgeway/version.rb +5 -0
  74. data/lib/edgeway.rb +30 -0
  75. metadata +115 -0
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "shellwords"
5
+
6
+ module Edgeway
7
+ module CLI
8
+ module Precheck
9
+ def self.sqlite!
10
+ return if ENV["EDGEWAY_SKIP_PRECHECK"] == "1"
11
+
12
+ cc = compiler
13
+ return if cc.nil? || sqlite_links?(cc)
14
+
15
+ raise Error, <<~MSG.chomp
16
+ libsqlite3 cannot be linked (`#{cc.join(" ")} -lsqlite3` failed); `edgeway test` needs the SQLite development library.
17
+ Debian/Ubuntu: sudo apt install libsqlite3-dev
18
+ Fedora: sudo dnf install sqlite-devel
19
+ macOS: brew install sqlite
20
+ If it is installed somewhere else, set LIBRARY_PATH to a directory holding libsqlite3.so.
21
+ Set EDGEWAY_SKIP_PRECHECK=1 to skip this check.
22
+ MSG
23
+ end
24
+
25
+ # The compiler command as an argv array, or nil when there is none.
26
+ def self.compiler
27
+ env = ENV["CC"].to_s.strip
28
+ unless env.empty?
29
+ argv = Shellwords.split(env)
30
+ return argv if !argv.empty? && (argv[0].include?("/") ? File.executable?(argv[0]) : Toolchain.find_on_path(argv[0]))
31
+ end
32
+ %w[cc gcc clang].each { |n| return [n] if Toolchain.find_on_path(n) }
33
+ nil
34
+ end
35
+
36
+ def self.sqlite_links?(cc)
37
+ Dir.mktmpdir("edgeway-precheck") do |dir|
38
+ src = File.join(dir, "t.c")
39
+ File.write(src, "int main(){return 0;}\n")
40
+ system(*cc, src, "-o", File.join(dir, "t"), "-lsqlite3", out: File::NULL, err: File::NULL) ? true : false
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgeway
4
+ module CLI
5
+ class Routes
6
+ def self.run(_args)
7
+ path = File.join(Dir.pwd, "config/routes.rb")
8
+ raise Error, "no config/routes.rb in #{Dir.pwd}" unless File.exist?(path)
9
+
10
+ $LOAD_PATH.unshift(File.join(CLI.gem_dir, "lib"))
11
+ require "edgeway"
12
+ load path
13
+ list = Edgeway.application.routes.list
14
+ verb_w = list.map { |r| r.verb.size }.max.to_i
15
+ path_w = list.map { |r| r.pattern.size }.max.to_i
16
+ list.each do |r|
17
+ puts "#{r.verb.ljust(verb_w)} #{r.pattern.ljust(path_w)} #{r.controller_path}##{r.action}"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgeway
4
+ module CLI
5
+ class Test
6
+ def self.run(args)
7
+ root = Dir.pwd
8
+ Precheck.sqlite!
9
+ new(root).write_test_app
10
+ exec("spinel_test-unit", "-I", root, "-I", CLI.gem_dir + "/lib", *args)
11
+ end
12
+
13
+ def initialize(root)
14
+ @root = root
15
+ end
16
+
17
+ def write_test_app
18
+ raise Error, "no config/routes.rb in #{@root}" unless File.exist?(File.join(@root, "config/routes.rb"))
19
+
20
+ FileUtils.mkdir_p(File.join(@root, "tmp"))
21
+ lines = ["# Generated by `edgeway test` from app/ and config/; do not edit.", 'require "edgeway"']
22
+ lines << 'require "edgeway/db/memory"'
23
+ lines << 'require "edgeway/db/sqlite"'
24
+ # Where the migrations are read from at test time (the binary may run
25
+ # from another directory); Edgeway::UnitTest#setup applies them.
26
+ lines << "module Edgeway; TEST_APP_ROOT = #{@root.dump}; end"
27
+ lines << ""
28
+ lines.concat(AppFiles.new(@root).require_lines)
29
+ File.write(File.join(@root, "tmp/test_app.rb"), lines.join("\n") + "\n")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgeway
4
+ module CLI
5
+ # Finds the Spinel compiler, for `edgeway build` and the Rakefile.
6
+ #
7
+ # 1. SPINEL (a Spinel checkout, for example)
8
+ # 2. spinel on PATH (mise puts its spinel there when it is activated)
9
+ # 3. `mise which spinel` (mise is installed but not activated)
10
+ #
11
+ # The WASI SDK that --target=wasm32-wasi needs is Spinel's to find: it
12
+ # reads WASI_SDK (mise.toml sets it), then WASI_SDK_PATH, and defaults to
13
+ # /opt/wasi-sdk.
14
+ module Toolchain
15
+ def self.spinel
16
+ env = ENV["SPINEL"]
17
+ return env unless env.nil? || env.empty?
18
+
19
+ find_on_path("spinel") || mise_which("spinel")
20
+ end
21
+
22
+ def self.find_on_path(name)
23
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).map { |d| File.join(d, name) }
24
+ .find { |f| File.file?(f) && File.executable?(f) }
25
+ end
26
+
27
+ def self.mise_which(name)
28
+ return nil unless find_on_path("mise")
29
+
30
+ out = IO.popen(["mise", "which", name], err: File::NULL, &:read).strip
31
+ $?.success? && !out.empty? ? out : nil
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The edgeway command (CRuby). Not part of the wasm: lib/edgeway.rb does not
4
+ # require it.
5
+ require "fileutils"
6
+ require "yaml"
7
+ require_relative "version"
8
+ require_relative "cli/app_config"
9
+ require_relative "cli/toolchain"
10
+ require_relative "cli/app_files"
11
+ require_relative "cli/build"
12
+ require_relative "cli/precheck"
13
+ require_relative "cli/test"
14
+ require_relative "cli/routes"
15
+ require_relative "cli/package_manager"
16
+ require_relative "cli/generate"
17
+ require_relative "cli/new"
18
+
19
+ module Edgeway
20
+ module CLI
21
+ USAGE = <<~TEXT
22
+ Usage: edgeway COMMAND [options]
23
+
24
+ new APP create a new app in APP/
25
+ build [--env ENV] compile the app to build/app.wasm (ENV: development)
26
+ test [ARGS] regenerate tmp/test_app.rb, run test/ with spinel_test-unit
27
+ generate, g KIND NAME generate a model (NAME field:type ...) or a controller (NAME action ...)
28
+ routes print the routes
29
+ version print the version
30
+ TEXT
31
+
32
+ class Error < StandardError; end
33
+
34
+ # The directory of the gem: lib/ is what Spinel gets as -I, js/ is
35
+ # copied into the app's build/.
36
+ def self.gem_dir
37
+ File.expand_path("../..", __dir__)
38
+ end
39
+
40
+ def self.start(argv)
41
+ command, *args = argv
42
+ case command
43
+ when "new" then New.run(args)
44
+ when "build" then Build.run(args)
45
+ when "test" then Test.run(args)
46
+ when "generate", "g" then Generate.run(args)
47
+ when "routes" then Routes.run(args)
48
+ when "version", "-v", "--version" then puts "edgeway #{Edgeway::VERSION}"
49
+ when nil, "help", "-h", "--help" then puts USAGE
50
+ else
51
+ warn "edgeway: unknown command #{command}\n\n#{USAGE}"
52
+ exit 1
53
+ end
54
+ rescue Error => e
55
+ warn "edgeway: #{e.message}"
56
+ exit 1
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,9 @@
1
+ module Edgeway
2
+ def self.env
3
+ ENV["EDGEWAY_ENV"] || "development"
4
+ end
5
+
6
+ def self.config(name, default = "")
7
+ ENV[name] || default
8
+ end
9
+ end
@@ -0,0 +1,42 @@
1
+ module Edgeway
2
+ module Controller
3
+ # Bearer-token authentication:
4
+ #
5
+ # class ApplicationController < Edgeway::Controller::Base
6
+ # before_action :authenticate!
7
+ # end
8
+ # # or before_action :authenticate!, only: [:create]
9
+ #
10
+ # authenticate! answers 401 unless the request has an
11
+ # "Authorization: Bearer <JWT>" whose token passes Edgeway::Auth; the
12
+ # claims are then in current_claims.
13
+ module Authentication
14
+ # Raises Unauthorized (no token) or InvalidToken. Both are 401 with a
15
+ # WWW-Authenticate header, unless the app rescue_froms them.
16
+ def authenticate!
17
+ token = bearer_token
18
+ raise Unauthorized, "authentication required" if token.empty?
19
+ @current_claims = Auth.decode(token)
20
+ @authenticated = true
21
+ nil
22
+ end
23
+
24
+ # The token's claims (a Hash of the JSON payload), {} when the request
25
+ # was not authenticated.
26
+ def current_claims
27
+ @authenticated ? @current_claims : {}
28
+ end
29
+
30
+ def authenticated? = @authenticated
31
+
32
+ private
33
+
34
+ def bearer_token
35
+ value = @request.header("Authorization")
36
+ scheme = "Bearer "
37
+ return "" unless value.length > scheme.length && value[0, scheme.length].downcase == "bearer "
38
+ value[scheme.length, value.length - scheme.length].strip
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,95 @@
1
+ module Edgeway
2
+ module Controller
3
+ class Base
4
+ include Callbacks
5
+ include Rescuable
6
+ include Rendering
7
+ include Authentication
8
+
9
+ NON_ACTIONS = ["render", "head", "headers", "params", "request", "performed?", "to_response",
10
+ "action?", "run_before_actions", "rescue_with_handler",
11
+ "authenticate!", "current_claims", "authenticated?"]
12
+
13
+ def self.before_action(name, only: [], except: [])
14
+ @own_before_actions ||= []
15
+ @own_before_actions << Callback.new(name, only.map(&:to_s), except.map(&:to_s))
16
+ end
17
+
18
+ def self.own_before_actions = (@own_before_actions || [])
19
+
20
+ def self.rescue_from(error_class, with:)
21
+ @own_handlers ||= []
22
+ @own_handlers << Handler.new(error_class, with)
23
+ end
24
+
25
+ def self.own_handlers = (@own_handlers || [])
26
+
27
+ def self.lineage
28
+ chain = []
29
+ k = self
30
+ while k
31
+ chain.unshift(k)
32
+ break if k == Base
33
+ k = k.superclass
34
+ end
35
+ chain
36
+ end
37
+
38
+ def self.all_before_actions
39
+ all = []
40
+ lineage.each { |c| c.own_before_actions.each { |cb| all << cb } }
41
+ all
42
+ end
43
+
44
+ def self.all_handlers
45
+ all = []
46
+ lineage.each { |c| c.own_handlers.each { |h| all << h } }
47
+ all
48
+ end
49
+
50
+ attr_reader :request
51
+
52
+ def initialize(request)
53
+ @request = request
54
+ @status = 200
55
+ @headers = {}
56
+ @response_body = ""
57
+ @performed = false
58
+ @params = nil
59
+ @current_claims = {}
60
+ @authenticated = false
61
+ end
62
+
63
+ def headers = @headers
64
+
65
+ def action?(name)
66
+ self.respond_to?(name.to_sym) && !NON_ACTIONS.include?(name)
67
+ end
68
+
69
+ def params
70
+ @params ||= wrapped_params
71
+ end
72
+
73
+ private
74
+
75
+ def wrapped_params
76
+ all = @request.params
77
+ scope = self.class.param_scope
78
+ prefix = "#{scope}["
79
+ return all unless @request.json?
80
+ return all if @request.body_params.keys.any? { |k| k.start_with?(prefix) }
81
+ wrapped = HTTP::Parameters.new(all.to_h.dup)
82
+ @request.body_params.each do |k, v|
83
+ wrapped["#{scope}[#{k}]"] = v unless k.include?("[")
84
+ end
85
+ wrapped
86
+ end
87
+
88
+ def self.param_scope
89
+ base = name.split("::").last.sub(/Controller\z/, "")
90
+ base = base.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
91
+ Inflector.singularize(base)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,29 @@
1
+ module Edgeway
2
+ module Controller
3
+ class Callback
4
+ attr_reader :name
5
+
6
+ def initialize(name, only, except)
7
+ @name = name
8
+ @only = only
9
+ @except = except
10
+ end
11
+
12
+ def applies?(action)
13
+ return false if @except.include?(action)
14
+ @only.empty? || @only.include?(action)
15
+ end
16
+ end
17
+
18
+ module Callbacks
19
+ def run_before_actions(action)
20
+ self.class.all_before_actions.each do |callback|
21
+ next unless callback.applies?(action)
22
+ send(callback.name)
23
+ return false if performed?
24
+ end
25
+ true
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,39 @@
1
+ module Edgeway
2
+ module Controller
3
+ module Dispatcher
4
+ def self.controller_class(route)
5
+ klass = nil
6
+ begin
7
+ klass = Object.const_get(route.controller_class_name)
8
+ rescue NameError
9
+ return nil
10
+ end
11
+ klass.is_a?(Class) && klass < Base ? klass : nil
12
+ end
13
+
14
+ def self.dispatch(route, request)
15
+ klass = controller_class(route)
16
+ raise RoutingError, "no controller for #{route.controller_path}" if klass.nil?
17
+ controller = klass.new(request)
18
+ # A route whose action isn't defined (or is one of Base's own public
19
+ # methods) is a 404, as in Rails.
20
+ raise RoutingError, "no route for #{request.method} #{request.path}" unless controller.action?(route.action)
21
+ begin
22
+ # The action is called here, on an instance of a class found by
23
+ # name, and not from a Base method: with a name taken as a method
24
+ # argument Spinel's send looks among the literals of the program,
25
+ # and misses a subclass's methods (matz/spinel#8592). public_send:
26
+ # a route can't reach a private method.
27
+ action = route.action.to_sym
28
+ controller.public_send(action) if controller.run_before_actions(route.action)
29
+ controller.head(:no_content) unless controller.performed?
30
+ rescue => e
31
+ raise e unless controller.rescue_with_handler(e)
32
+ # A handler that rendered nothing answers 204, as an action does.
33
+ controller.head(:no_content) unless controller.performed?
34
+ end
35
+ controller.to_response
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,65 @@
1
+ module Edgeway
2
+ module Controller
3
+ module Rendering
4
+ STATUS_CODES = {
5
+ "ok" => 200, "created" => 201, "accepted" => 202, "no_content" => 204,
6
+ "moved_permanently" => 301, "found" => 302, "not_modified" => 304,
7
+ "bad_request" => 400, "unauthorized" => 401, "forbidden" => 403, "not_found" => 404,
8
+ "method_not_allowed" => 405, "conflict" => 409, "unprocessable_entity" => 422,
9
+ "too_many_requests" => 429, "internal_server_error" => 500, "not_implemented" => 501,
10
+ "service_unavailable" => 503
11
+ }
12
+
13
+ def self.json_response(status, json_text)
14
+ HTTP::Response.json(status, json_text)
15
+ end
16
+
17
+ def self.status_code(status)
18
+ return status if status.is_a?(Integer)
19
+ code = STATUS_CODES[status.to_s]
20
+ raise ArgumentError, "unknown status: #{status.inspect}" if code.nil?
21
+ code
22
+ end
23
+
24
+ def self.json_value(obj)
25
+ if obj.is_a?(Model::Relation)
26
+ obj.to_a.map { |m| m.as_json }
27
+ elsif obj.is_a?(Model::Base)
28
+ obj.as_json
29
+ elsif obj.is_a?(HTTP::Parameters)
30
+ obj.to_h
31
+ elsif obj.is_a?(Array)
32
+ obj.map { |x| json_value(x) }
33
+ elsif obj.is_a?(Hash)
34
+ h = {}
35
+ obj.each { |k, v| h[k] = json_value(v) }
36
+ h
37
+ else
38
+ obj
39
+ end
40
+ end
41
+
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"
46
+ @performed = true
47
+ end
48
+
49
+ def head(status)
50
+ @status = Rendering.status_code(status)
51
+ @response_body = ""
52
+ @performed = true
53
+ end
54
+
55
+ def performed? = @performed
56
+
57
+ def to_response
58
+ response = HTTP::Response.new(@status, @response_body)
59
+ @headers.each { |k, v| response.headers[k] = v }
60
+ response.check_headers
61
+ response
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,26 @@
1
+ module Edgeway
2
+ module Controller
3
+ class Handler
4
+ attr_reader :error_class, :method_name
5
+
6
+ def initialize(error_class, method_name)
7
+ @error_class = error_class
8
+ @method_name = method_name
9
+ end
10
+ end
11
+
12
+ module Rescuable
13
+ # true when a handler took the error. The handler is called with the
14
+ # error, as in Rails (def not_found(e)).
15
+ def rescue_with_handler(error)
16
+ self.class.all_handlers.reverse.each do |handler|
17
+ if error.is_a?(handler.error_class)
18
+ send(handler.method_name, error)
19
+ return true
20
+ end
21
+ end
22
+ false
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,32 @@
1
+ module Edgeway
2
+ module DB
3
+ class Adapter
4
+ def select_all(sql, binds)
5
+ raise Error, "#{self.class} does not define select_all"
6
+ end
7
+
8
+ def execute(sql, binds)
9
+ raise Error, "#{self.class} does not define execute"
10
+ end
11
+
12
+ def batch(statements)
13
+ raise Error, "#{self.class} does not define batch"
14
+ end
15
+
16
+ def insert_returning(sql, binds)
17
+ rows = select_all(sql, binds)
18
+ raise DatabaseError, "INSERT ... RETURNING answered no row" if rows.empty?
19
+ rows[0]
20
+ end
21
+
22
+ def fail_with(message)
23
+ raise RecordNotUnique, message if message.include?("UNIQUE constraint failed")
24
+ raise DatabaseError, message
25
+ end
26
+
27
+ def supports_returning? = true
28
+ def placeholder(n) = "?"
29
+ def quote_boolean(value) = value ? 1 : 0
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,73 @@
1
+ require_relative "adapter"
2
+
3
+ module Edgeway
4
+ module DB
5
+ # An adapter for tests: it records the SQL it is given and answers the
6
+ # rows the test queued (none: an empty answer). Nothing is stored.
7
+ #
8
+ # db = Edgeway::DB::MemoryAdapter.new
9
+ # Edgeway::Model.connection = db
10
+ # db.queue([{ "id" => 1, "title" => "a" }])
11
+ # Todo.find(1)
12
+ # puts db.log # SELECT ... | [1]
13
+ class MemoryAdapter < Adapter
14
+ attr_reader :log
15
+ attr_writer :returning
16
+ # The "changes" count execute answers (1 by default), for delete_all.
17
+ attr_writer :changes
18
+
19
+ def initialize
20
+ @log = []
21
+ @queue = []
22
+ @returning = true
23
+ @changes = 1
24
+ @failure = nil
25
+ @failure_on = ""
26
+ @next_id = 1
27
+ end
28
+
29
+ # The rows the next query answers (one queue entry per query).
30
+ def queue(rows)
31
+ @queue << rows
32
+ end
33
+
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.
37
+ def fail_next(message, containing = "")
38
+ @failure = message
39
+ @failure_on = containing
40
+ end
41
+
42
+ def supports_returning? = @returning
43
+
44
+ def select_all(sql, binds)
45
+ record(sql, binds)
46
+ @queue.empty? ? [] : @queue.shift
47
+ end
48
+
49
+ def execute(sql, binds)
50
+ record(sql, binds)
51
+ id = @next_id
52
+ @next_id += 1
53
+ { "changes" => @changes, "last_insert_id" => id }
54
+ end
55
+
56
+ def batch(statements)
57
+ @log << "BATCH #{statements.size}"
58
+ statements.map { |s| execute(s[0], s[1]) }
59
+ end
60
+
61
+ private
62
+
63
+ def record(sql, binds)
64
+ @log << "#{sql} | #{JSON.generate(binds)}"
65
+ if !@failure.nil? && sql.include?(@failure_on)
66
+ message = @failure
67
+ @failure = nil
68
+ fail_with(message)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end