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,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