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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +444 -0
- data/exe/edgeway +6 -0
- data/js/cgi.js +157 -0
- data/js/cloudflare.js +103 -0
- data/js/cors.js +87 -0
- data/js/jwt.js +279 -0
- data/js/runtime.js +99 -0
- data/js/wasi-shim.js +191 -0
- data/lib/edgeway/application.rb +70 -0
- data/lib/edgeway/cli/app_config.rb +35 -0
- data/lib/edgeway/cli/app_files.rb +37 -0
- data/lib/edgeway/cli/build.rb +90 -0
- data/lib/edgeway/cli/generate.rb +160 -0
- data/lib/edgeway/cli/new.rb +43 -0
- data/lib/edgeway/cli/package_manager.rb +21 -0
- data/lib/edgeway/cli/precheck.rb +45 -0
- data/lib/edgeway/cli/routes.rb +22 -0
- data/lib/edgeway/cli/test.rb +33 -0
- data/lib/edgeway/cli/toolchain.rb +35 -0
- data/lib/edgeway/cli.rb +59 -0
- data/lib/edgeway/config.rb +9 -0
- data/lib/edgeway/controller/authentication.rb +42 -0
- data/lib/edgeway/controller/base.rb +95 -0
- data/lib/edgeway/controller/callbacks.rb +29 -0
- data/lib/edgeway/controller/dispatcher.rb +39 -0
- data/lib/edgeway/controller/rendering.rb +65 -0
- data/lib/edgeway/controller/rescuable.rb +26 -0
- data/lib/edgeway/db/adapter.rb +32 -0
- data/lib/edgeway/db/memory.rb +73 -0
- data/lib/edgeway/db/sqlite.rb +190 -0
- data/lib/edgeway/errors.rb +61 -0
- data/lib/edgeway/generators/app/templates/.gitignore +6 -0
- data/lib/edgeway/generators/app/templates/Gemfile.tt +4 -0
- data/lib/edgeway/generators/app/templates/README.md.tt +22 -0
- data/lib/edgeway/generators/app/templates/app/controllers/application_controller.rb +9 -0
- data/lib/edgeway/generators/app/templates/app/controllers/health_controller.rb +3 -0
- data/lib/edgeway/generators/app/templates/app/models/application_model.rb +3 -0
- data/lib/edgeway/generators/app/templates/bin/build.tt +7 -0
- data/lib/edgeway/generators/app/templates/bin/deploy.tt +10 -0
- data/lib/edgeway/generators/app/templates/bin/setup.tt +38 -0
- data/lib/edgeway/generators/app/templates/config/database.yml +14 -0
- data/lib/edgeway/generators/app/templates/config/routes.rb +4 -0
- data/lib/edgeway/generators/app/templates/db/migrations/.keep +0 -0
- data/lib/edgeway/generators/app/templates/mise.toml.tt +24 -0
- data/lib/edgeway/generators/app/templates/package.json.tt +7 -0
- data/lib/edgeway/generators/app/templates/src/worker.js +4 -0
- data/lib/edgeway/generators/app/templates/test/controllers/health_test.rb +16 -0
- data/lib/edgeway/generators/app/templates/test/test_helper.rb +1 -0
- data/lib/edgeway/generators/app/templates/wrangler.toml.tt +23 -0
- data/lib/edgeway/http/auth/jwt.rb +127 -0
- data/lib/edgeway/http/auth/stub.rb +29 -0
- data/lib/edgeway/http/json_body.rb +37 -0
- data/lib/edgeway/http/parameters.rb +42 -0
- data/lib/edgeway/http/request.rb +115 -0
- data/lib/edgeway/http/response.rb +46 -0
- data/lib/edgeway/inflector.rb +16 -0
- data/lib/edgeway/logger.rb +21 -0
- data/lib/edgeway/model/attributes.rb +85 -0
- data/lib/edgeway/model/base.rb +173 -0
- data/lib/edgeway/model/macros.rb +60 -0
- data/lib/edgeway/model/persistence.rb +154 -0
- data/lib/edgeway/model/relation.rb +295 -0
- data/lib/edgeway/model/validations.rb +136 -0
- data/lib/edgeway/routing/mapper.rb +57 -0
- data/lib/edgeway/routing/route.rb +39 -0
- data/lib/edgeway/routing/route_set.rb +27 -0
- data/lib/edgeway/server.rb +12 -0
- data/lib/edgeway/service/cloudflare/d1.rb +101 -0
- data/lib/edgeway/service/cloudflare/jwt.rb +44 -0
- data/lib/edgeway/test.rb +97 -0
- data/lib/edgeway/version.rb +5 -0
- data/lib/edgeway.rb +30 -0
- metadata +115 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Edgeway
|
|
2
|
+
module HTTP
|
|
3
|
+
class Parameters
|
|
4
|
+
# permitted: whether the values may be mass-assigned to a model. Only
|
|
5
|
+
# what permit returns is; the request's params are not.
|
|
6
|
+
def initialize(values = {}, permitted = false)
|
|
7
|
+
@values = values
|
|
8
|
+
@permitted = permitted
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def permitted? = @permitted
|
|
12
|
+
|
|
13
|
+
def [](key) = @values[key.to_s]
|
|
14
|
+
def []=(key, value)
|
|
15
|
+
@values[key.to_s] = value
|
|
16
|
+
end
|
|
17
|
+
def key?(key) = @values.key?(key.to_s)
|
|
18
|
+
def fetch(key, default) = @values.key?(key.to_s) ? @values[key.to_s] : default
|
|
19
|
+
def empty? = @values.empty?
|
|
20
|
+
def to_h = @values
|
|
21
|
+
|
|
22
|
+
def require(scope)
|
|
23
|
+
prefix = "#{scope}["
|
|
24
|
+
found = {}
|
|
25
|
+
@values.each do |k, v|
|
|
26
|
+
found[k[prefix.length...-1]] = v if k.start_with?(prefix) && k.end_with?("]")
|
|
27
|
+
end
|
|
28
|
+
raise ParameterMissing, "param is missing or the value is empty: #{scope}" if found.empty?
|
|
29
|
+
Parameters.new(found)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def permit(*keys)
|
|
33
|
+
found = {}
|
|
34
|
+
keys.each do |key|
|
|
35
|
+
name = key.to_s
|
|
36
|
+
found[name] = @values[name] if @values.key?(name)
|
|
37
|
+
end
|
|
38
|
+
Parameters.new(found, true)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
module Edgeway
|
|
2
|
+
module HTTP
|
|
3
|
+
class Request
|
|
4
|
+
REPLACEMENT = "\uFFFD"
|
|
5
|
+
|
|
6
|
+
attr_reader :method, :path, :query_string, :content_type, :body, :params, :body_params
|
|
7
|
+
|
|
8
|
+
def self.from_env
|
|
9
|
+
new(ENV["REQUEST_METHOD"] || "GET", ENV["PATH_INFO"] || "/", ENV["QUERY_STRING"] || "",
|
|
10
|
+
ENV["CONTENT_TYPE"] || "", STDIN.read || "")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(method, path, query_string, content_type, body)
|
|
14
|
+
@method = method
|
|
15
|
+
@path = path
|
|
16
|
+
@query_string = query_string
|
|
17
|
+
@content_type = content_type
|
|
18
|
+
@body = body
|
|
19
|
+
@params = Parameters.new({})
|
|
20
|
+
@body_params = {}
|
|
21
|
+
@json_body = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def header(name)
|
|
25
|
+
ENV["HTTP_" + name.upcase.gsub("-", "_")] || ""
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def json?
|
|
29
|
+
@content_type.start_with?("application/json")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def form?
|
|
33
|
+
@content_type.start_with?("application/x-www-form-urlencoded")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def json_body
|
|
37
|
+
@json_body
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def read_body
|
|
41
|
+
read_query
|
|
42
|
+
return if @body.empty?
|
|
43
|
+
raise BadRequest, "body is not valid UTF-8" unless @body.valid_encoding?
|
|
44
|
+
if json?
|
|
45
|
+
read_json
|
|
46
|
+
elsif form?
|
|
47
|
+
read_form
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def add_path_params(path_params)
|
|
52
|
+
path_params.each { |k, v| @params[k] = v }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def read_query
|
|
58
|
+
return if @query_string.empty?
|
|
59
|
+
decode_pairs(@query_string, "query string").each { |k, v| @params[k] = v }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def read_form
|
|
63
|
+
add_body_params(decode_pairs(@body, "form body"))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# decode_www_form never raises on bad bytes: it turns those that aren't
|
|
67
|
+
# UTF-8 (%ff) into U+FFFD. That is only right when the client sent a
|
|
68
|
+
# U+FFFD itself, so otherwise one in the result is a BadRequest.
|
|
69
|
+
def decode_pairs(text, what)
|
|
70
|
+
raise BadRequest, "#{what} is not valid UTF-8" unless text.valid_encoding?
|
|
71
|
+
pairs = decode_www_form(text)
|
|
72
|
+
sent = text.include?(REPLACEMENT) || text.downcase.include?("%ef%bf%bd")
|
|
73
|
+
unless sent
|
|
74
|
+
pairs.each do |k, v|
|
|
75
|
+
raise BadRequest, "#{what} is not valid UTF-8" if k.include?(REPLACEMENT) || v.include?(REPLACEMENT)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
pairs
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# URI.decode_www_form, written out so that Edgeway doesn't require
|
|
82
|
+
# "uri": a send with a computed name (Dispatcher) reaches uri's
|
|
83
|
+
# find_proxy(env = ENV), which Spinel refuses since its Strings are
|
|
84
|
+
# shared by reference (matz/spinel 44119ab30). Same answers as uri's,
|
|
85
|
+
# ArgumentError for a non-ASCII input included.
|
|
86
|
+
def decode_www_form(text)
|
|
87
|
+
raise ArgumentError, "the input of URI.decode_www_form must be ASCII only string" unless text.ascii_only?
|
|
88
|
+
return [] if text.empty?
|
|
89
|
+
pieces = text.split("&", -1)
|
|
90
|
+
pieces.pop if pieces.last.empty?
|
|
91
|
+
pieces.map do |piece|
|
|
92
|
+
key, _sep, val = piece.partition("=")
|
|
93
|
+
[unescape_www(key), unescape_www(val)]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def unescape_www(s)
|
|
98
|
+
s.b.gsub(/\+|%\h\h/) { |m| m == "+" ? " " : m[1, 2].hex.chr }.force_encoding(Encoding::UTF_8).scrub
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def read_json
|
|
102
|
+
json = JSONBody.new(@body)
|
|
103
|
+
@json_body = json.value
|
|
104
|
+
add_body_params(json.params)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def add_body_params(pairs)
|
|
108
|
+
pairs.each do |k, v|
|
|
109
|
+
@params[k] = v
|
|
110
|
+
@body_params[k] = v
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Edgeway
|
|
2
|
+
module HTTP
|
|
3
|
+
class Response
|
|
4
|
+
# The characters of an HTTP token (RFC 9110), which is what a header
|
|
5
|
+
# name is.
|
|
6
|
+
TOKEN = /\A[A-Za-z0-9!#$%&'*+.^_`|~-]+\z/
|
|
7
|
+
|
|
8
|
+
attr_accessor :status, :body
|
|
9
|
+
attr_reader :headers
|
|
10
|
+
|
|
11
|
+
def initialize(status = 200, body = "")
|
|
12
|
+
@status = status
|
|
13
|
+
@headers = {}
|
|
14
|
+
@body = body
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.json(status, json_text)
|
|
18
|
+
response = new(status, json_text + "\n")
|
|
19
|
+
response.headers["Content-Type"] = "application/json"
|
|
20
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
21
|
+
response
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Raises InvalidHeader unless every header name is an HTTP token and no
|
|
25
|
+
# value holds CR, LF or NUL: js/cgi.js reads each line of to_cgi as a
|
|
26
|
+
# header, so a newline in a value would let a client add headers, a
|
|
27
|
+
# second Status or the body (headers["X-Name"] = params["name"]).
|
|
28
|
+
def check_headers
|
|
29
|
+
@headers.each do |k, v|
|
|
30
|
+
raise InvalidHeader, "bad header name: #{k.to_s.inspect}" unless TOKEN.match?(k.to_s)
|
|
31
|
+
text = v.to_s
|
|
32
|
+
if text.include?("\n") || text.include?("\r") || text.include?("\0")
|
|
33
|
+
raise InvalidHeader, "bad value for header #{k}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_cgi
|
|
39
|
+
check_headers
|
|
40
|
+
out = "Status: #{@status}\n"
|
|
41
|
+
@headers.each { |k, v| out += "#{k}: #{v}\n" }
|
|
42
|
+
out + "\n" + @body
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Edgeway
|
|
2
|
+
module Inflector
|
|
3
|
+
def self.singularize(name)
|
|
4
|
+
n = name.to_s
|
|
5
|
+
return n[0...-3] + "y" if n.end_with?("ies")
|
|
6
|
+
return n[0...-1] if n.end_with?("s")
|
|
7
|
+
n
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.pluralize(name)
|
|
11
|
+
n = name.to_s
|
|
12
|
+
return n[0...-1] + "ies" if n.end_with?("y") && n.length > 1 && !"aeiou".include?(n[-2])
|
|
13
|
+
n + "s"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Edgeway
|
|
2
|
+
# Writes to stderr; the runtime sends it to console.error.
|
|
3
|
+
class Logger
|
|
4
|
+
def info(message)
|
|
5
|
+
STDERR.puts "[edgeway] #{message}"
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def error(message)
|
|
9
|
+
STDERR.puts "[edgeway] ERROR #{message}"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.logger
|
|
14
|
+
@logger ||= Logger.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# For tests that want to see what is logged.
|
|
18
|
+
def self.logger=(logger)
|
|
19
|
+
@logger = logger
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
module Edgeway
|
|
2
|
+
module Model
|
|
3
|
+
TRUE_STRINGS = ["true", "1", "t", "on"]
|
|
4
|
+
|
|
5
|
+
# Integer is 32-bit in a wasm build: to_i raises RangeError beyond these.
|
|
6
|
+
# to_int checks them too, so CRuby and a native build (64-bit) cast the
|
|
7
|
+
# same values to nil as the wasm does.
|
|
8
|
+
INT_MAX = 2147483647
|
|
9
|
+
INT_MIN = -2147483648
|
|
10
|
+
|
|
11
|
+
def self.connection=(adapter)
|
|
12
|
+
@connection = adapter
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.connection
|
|
16
|
+
@connection || raise(ConfigurationError, "Edgeway::Model.connection is not set")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# 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).
|
|
21
|
+
def self.batch
|
|
22
|
+
if @batch_queue
|
|
23
|
+
yield
|
|
24
|
+
return []
|
|
25
|
+
end
|
|
26
|
+
queue = []
|
|
27
|
+
@batch_queue = queue
|
|
28
|
+
begin
|
|
29
|
+
yield
|
|
30
|
+
rescue => e
|
|
31
|
+
@batch_queue = nil
|
|
32
|
+
raise e
|
|
33
|
+
end
|
|
34
|
+
@batch_queue = nil
|
|
35
|
+
queue.empty? ? [] : connection.batch(queue)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.batching? = !@batch_queue.nil?
|
|
39
|
+
|
|
40
|
+
# Every model write but an INSERT goes through here: executed at once, or
|
|
41
|
+
# queued inside batch. A queued write answers a placeholder result, as its
|
|
42
|
+
# counts are not known until the batch is sent.
|
|
43
|
+
def self.write(sql, binds)
|
|
44
|
+
queue = @batch_queue
|
|
45
|
+
if queue.nil?
|
|
46
|
+
connection.execute(sql, binds)
|
|
47
|
+
else
|
|
48
|
+
queue << [sql, binds]
|
|
49
|
+
{ "changes" => 0, "last_insert_id" => 0 }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.to_int(v)
|
|
54
|
+
n = v.is_a?(Integer) ? v : v.to_s.to_i
|
|
55
|
+
n > INT_MAX || n < INT_MIN ? nil : n
|
|
56
|
+
rescue RangeError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.cast(type, v)
|
|
61
|
+
return nil if v.nil?
|
|
62
|
+
case type
|
|
63
|
+
when :integer then to_int(v)
|
|
64
|
+
when :float then v.is_a?(Float) ? v : v.to_s.to_f
|
|
65
|
+
when :boolean then v == true || TRUE_STRINGS.include?(v.to_s)
|
|
66
|
+
else v.to_s
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.to_db(v)
|
|
71
|
+
return connection.quote_boolean(v) if v == true || v == false
|
|
72
|
+
v
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.underscore(name)
|
|
76
|
+
name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.now = @frozen_time || Time.now.utc.iso8601(3)
|
|
80
|
+
|
|
81
|
+
def self.freeze_time(text)
|
|
82
|
+
@frozen_time = text
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
module Edgeway
|
|
2
|
+
module Model
|
|
3
|
+
class Base
|
|
4
|
+
include Persistence
|
|
5
|
+
include Validations
|
|
6
|
+
|
|
7
|
+
# The model an association name ("comments", "todo") stands for. owner is
|
|
8
|
+
# the full name of the model that declares it: the namespaces around the
|
|
9
|
+
# owner are tried first, innermost out ("Admin::Comment" for an
|
|
10
|
+
# Admin::Post), then the top level.
|
|
11
|
+
def self.model_named(name, owner = "")
|
|
12
|
+
base = Inflector.singularize(name).split("_").map(&:capitalize).join
|
|
13
|
+
scope = owner.split("::")
|
|
14
|
+
scope.pop
|
|
15
|
+
klass = nil
|
|
16
|
+
until klass || scope.empty?
|
|
17
|
+
klass = model_class((scope + [base]).join("::"))
|
|
18
|
+
scope.pop
|
|
19
|
+
end
|
|
20
|
+
klass ||= model_class(base)
|
|
21
|
+
raise ArgumentError, "no model for #{name}" if klass.nil?
|
|
22
|
+
klass
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.model_class(full_name)
|
|
26
|
+
klass = nil
|
|
27
|
+
begin
|
|
28
|
+
klass = Object.const_get(full_name)
|
|
29
|
+
rescue NameError
|
|
30
|
+
return nil
|
|
31
|
+
end
|
|
32
|
+
klass.is_a?(Class) && klass < Base ? klass : nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.table_name=(name)
|
|
36
|
+
@table_name = name
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.table_name
|
|
40
|
+
@table_name || Inflector.pluralize(Model.underscore(name.split("::").last))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.attribute_types = (@attribute_types ||= {})
|
|
44
|
+
def self.attribute_defaults = (@attribute_defaults ||= {})
|
|
45
|
+
def self.belongs_to_list = (@belongs_to ||= [])
|
|
46
|
+
def self.keep_type(col)
|
|
47
|
+
@kept_types ||= {}
|
|
48
|
+
@kept_types[col] = attribute_types[col] if attribute_types.key?(col)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.restore_type(col)
|
|
52
|
+
@kept_types ||= {}
|
|
53
|
+
attribute_types[col] = @kept_types[col] if @kept_types.key?(col)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.rules = (@rules ||= [])
|
|
57
|
+
def self.has_manys = (@has_manys ||= [])
|
|
58
|
+
|
|
59
|
+
# The has_manys with a dependent: option.
|
|
60
|
+
def self.dependents = has_manys.reject { |h| h.mode.nil? }
|
|
61
|
+
|
|
62
|
+
def self.validates(*names, presence: false, length: nil, inclusion: nil, numericality: nil, format: nil, uniqueness: nil)
|
|
63
|
+
names.each do |n|
|
|
64
|
+
attr = n.to_s
|
|
65
|
+
rules << Rule.new(attr, "presence", {}, [], nil) if presence
|
|
66
|
+
rules << Rule.new(attr, "length", Rule.bounds(length), [], nil) unless length.nil?
|
|
67
|
+
rules << Rule.new(attr, "inclusion", {}, inclusion[:in].map(&:to_s), nil) unless inclusion.nil?
|
|
68
|
+
rules << Rule.new(attr, "numericality", Rule.bounds(numericality), [], nil) unless numericality.nil?
|
|
69
|
+
rules << Rule.new(attr, "format", {}, [], format[:with]) unless format.nil?
|
|
70
|
+
# The scope columns are in the list slot.
|
|
71
|
+
rules << Rule.new(attr, "uniqueness", {}, Rule.scope_columns(uniqueness), nil) unless uniqueness.nil? || uniqueness == false
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.column(name)
|
|
76
|
+
col = name.to_s
|
|
77
|
+
raise UnknownColumn, "unknown column: #{col}" unless col == "id" || attribute_types.key?(col)
|
|
78
|
+
col
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.columns
|
|
82
|
+
["id"] + attribute_types.keys
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.type_of(col)
|
|
86
|
+
col == "id" ? :integer : attribute_types[col]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.all = Relation.new(self)
|
|
90
|
+
def self.where(conditions) = all.where(conditions)
|
|
91
|
+
def self.order(col, dir = :asc) = all.order(col, dir)
|
|
92
|
+
def self.reorder(col, dir = :asc) = all.reorder(col, dir)
|
|
93
|
+
def self.delete_all = all.delete_all
|
|
94
|
+
def self.destroy_all = all.destroy_all
|
|
95
|
+
def self.eager_load(*names) = all.eager_load(*names)
|
|
96
|
+
def self.limit(n) = all.limit(n)
|
|
97
|
+
def self.offset(n) = all.offset(n)
|
|
98
|
+
def self.count = all.count
|
|
99
|
+
def self.exists?(conditions = {}) = all.where(conditions).exists?
|
|
100
|
+
def self.pluck(col) = all.pluck(col)
|
|
101
|
+
def self.first = all.first
|
|
102
|
+
def self.find_by(conditions) = all.find_by(conditions)
|
|
103
|
+
def self.find(id) = all.find(id)
|
|
104
|
+
|
|
105
|
+
def self.find_or_create_by(conditions) = all.find_or_create_by(conditions)
|
|
106
|
+
def self.find_or_create_by!(conditions) = all.find_or_create_by!(conditions)
|
|
107
|
+
|
|
108
|
+
def self.create(attrs = {})
|
|
109
|
+
record = new(attrs)
|
|
110
|
+
record.save
|
|
111
|
+
record
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.create!(attrs = {})
|
|
115
|
+
record = new(attrs)
|
|
116
|
+
record.save!
|
|
117
|
+
record
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.instantiate(row)
|
|
121
|
+
record = new({})
|
|
122
|
+
record.load_row(row)
|
|
123
|
+
record
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def initialize(attrs = {})
|
|
127
|
+
@attributes = {}
|
|
128
|
+
@raw = {}
|
|
129
|
+
@errors = []
|
|
130
|
+
@persisted = false
|
|
131
|
+
@loaded_children = {}
|
|
132
|
+
@loaded_parents = {}
|
|
133
|
+
self.class.attribute_defaults.each { |k, v| write_attribute(k, v) }
|
|
134
|
+
assign(attrs)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def id = @attributes["id"]
|
|
138
|
+
def persisted? = @persisted
|
|
139
|
+
def new_record? = !@persisted
|
|
140
|
+
|
|
141
|
+
def assign(attrs)
|
|
142
|
+
if attrs.is_a?(HTTP::Parameters)
|
|
143
|
+
raise ForbiddenAttributes, "#{self.class.name}: params must go through permit" unless attrs.permitted?
|
|
144
|
+
values = attrs.to_h
|
|
145
|
+
else
|
|
146
|
+
values = attrs
|
|
147
|
+
end
|
|
148
|
+
types = self.class.attribute_types
|
|
149
|
+
values.each do |k, v|
|
|
150
|
+
key = k.to_s
|
|
151
|
+
write_attribute(key, v) if types.key?(key)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def load_row(row)
|
|
156
|
+
self.class.columns.each { |col| @attributes[col] = Model.cast(self.class.type_of(col), row[col]) }
|
|
157
|
+
@raw = {}
|
|
158
|
+
@persisted = true
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def write_attribute(col, v)
|
|
162
|
+
@raw[col] = v
|
|
163
|
+
@attributes[col] = Model.cast(self.class.type_of(col), v)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def as_json
|
|
167
|
+
h = {}
|
|
168
|
+
self.class.columns.each { |col| h[col] = @attributes[col] }
|
|
169
|
+
h
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Edgeway
|
|
2
|
+
module Model
|
|
3
|
+
module Macros
|
|
4
|
+
def attribute(name, type, default: nil)
|
|
5
|
+
attribute_types[name.to_s] = type
|
|
6
|
+
attribute_defaults[name.to_s] = default unless default.nil?
|
|
7
|
+
define_method(name) { @attributes[name.to_s] }
|
|
8
|
+
define_method("#{name}=") { |v| write_attribute(name.to_s, v) }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# created_at / updated_at, as ISO 8601 text: SQLite (so D1) has no datetime
|
|
12
|
+
# column type, the migrations declare them TEXT.
|
|
13
|
+
def timestamps
|
|
14
|
+
attribute :created_at, :datetime
|
|
15
|
+
attribute :updated_at, :datetime
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# dependent: :destroy destroys the children (their own dependents run
|
|
19
|
+
# too), :delete_all deletes them with one DELETE. foreign_key: is the
|
|
20
|
+
# children's column and primary_key: ours (a String foreign key is
|
|
21
|
+
# declared with attribute on the child, as for belongs_to). An unset
|
|
22
|
+
# foreign_key stays nil: association works out "<model>_id" per call.
|
|
23
|
+
def has_many(name, dependent: nil, foreign_key: nil, primary_key: nil)
|
|
24
|
+
mode = nil
|
|
25
|
+
unless dependent.nil?
|
|
26
|
+
mode = dependent.to_s
|
|
27
|
+
raise ArgumentError, "unknown dependent: #{dependent}" unless mode == "destroy" || mode == "delete_all"
|
|
28
|
+
end
|
|
29
|
+
fk = foreign_key.nil? ? nil : foreign_key.to_s
|
|
30
|
+
pk = primary_key.nil? ? nil : primary_key.to_s
|
|
31
|
+
has_manys << Edgeway::Model::HasMany.new(name.to_s, fk, pk, mode)
|
|
32
|
+
define_method(name) do
|
|
33
|
+
association(name.to_s)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# foreign_key: is our column ("<name>_id" by default), primary_key: the
|
|
38
|
+
# parent's ("id"). A foreign key that is already declared keeps its type,
|
|
39
|
+
# which is how a String one is made: attribute :owner_uid, :string first.
|
|
40
|
+
# attribute is called either way (Spinel needs a macro call to be
|
|
41
|
+
# unconditional, and a macro body can't read attribute_types into a
|
|
42
|
+
# local), so keep_type/restore_type put the declared type back.
|
|
43
|
+
def belongs_to(name, foreign_key: nil, primary_key: nil)
|
|
44
|
+
fk = foreign_key.nil? ? "#{name}_id" : foreign_key.to_s
|
|
45
|
+
pk = primary_key.nil? ? "id" : primary_key.to_s
|
|
46
|
+
keep_type(fk)
|
|
47
|
+
if foreign_key.nil?
|
|
48
|
+
attribute "#{name}_id", :integer
|
|
49
|
+
else
|
|
50
|
+
attribute foreign_key, :integer
|
|
51
|
+
end
|
|
52
|
+
restore_type(fk)
|
|
53
|
+
belongs_to_list << Edgeway::Model::BelongsTo.new(name.to_s, fk, pk)
|
|
54
|
+
define_method(name) do
|
|
55
|
+
parent_of(name.to_s)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|