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,154 @@
|
|
|
1
|
+
module Edgeway
|
|
2
|
+
module Model
|
|
3
|
+
# A has_many: the association's name, its foreign key and primary key (nil
|
|
4
|
+
# for the defaults) and its dependent mode (nil, "destroy" or "delete_all").
|
|
5
|
+
class HasMany
|
|
6
|
+
attr_reader :name, :foreign_key, :primary_key, :mode
|
|
7
|
+
|
|
8
|
+
def initialize(name, foreign_key, primary_key, mode)
|
|
9
|
+
@name = name
|
|
10
|
+
@foreign_key = foreign_key
|
|
11
|
+
@primary_key = primary_key
|
|
12
|
+
@mode = mode
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# A belongs_to: the association's name, our foreign key column and the
|
|
17
|
+
# parent's primary key column.
|
|
18
|
+
class BelongsTo
|
|
19
|
+
attr_reader :name, :foreign_key, :primary_key
|
|
20
|
+
|
|
21
|
+
def initialize(name, foreign_key, primary_key)
|
|
22
|
+
@name = name
|
|
23
|
+
@foreign_key = foreign_key
|
|
24
|
+
@primary_key = primary_key
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
module Persistence
|
|
29
|
+
def save
|
|
30
|
+
return false unless valid?
|
|
31
|
+
touch(!@persisted)
|
|
32
|
+
if @persisted
|
|
33
|
+
update_row
|
|
34
|
+
else
|
|
35
|
+
insert_row
|
|
36
|
+
end
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def save!
|
|
41
|
+
raise RecordInvalid.new(@errors) unless save
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def update(attrs)
|
|
46
|
+
assign(attrs)
|
|
47
|
+
save
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def update!(attrs)
|
|
51
|
+
assign(attrs)
|
|
52
|
+
save!
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def destroy
|
|
56
|
+
if self.class.dependents.empty?
|
|
57
|
+
delete_row
|
|
58
|
+
else
|
|
59
|
+
Model.batch do
|
|
60
|
+
self.class.dependents.each do |d|
|
|
61
|
+
if d.mode == "destroy"
|
|
62
|
+
association(d.name).destroy_all
|
|
63
|
+
else
|
|
64
|
+
association(d.name).delete_all
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
delete_row
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
@persisted = false
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# eager_load keeps what it loaded here: association and parent_of answer
|
|
75
|
+
# from it without a query.
|
|
76
|
+
def cache_children(name, relation)
|
|
77
|
+
@loaded_children[name] = relation
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def cache_parent(name, record)
|
|
81
|
+
@loaded_parents[name] = record
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def association(name)
|
|
85
|
+
return @loaded_children[name] if @loaded_children.key?(name)
|
|
86
|
+
cfg = nil
|
|
87
|
+
self.class.has_manys.each { |h| cfg = h if h.name == name }
|
|
88
|
+
raise ArgumentError, "no has_many #{name}" if cfg.nil?
|
|
89
|
+
# .dup: since matz/spinel 44119ab30 (Strings shared by reference),
|
|
90
|
+
# Spinel refuses this String as it is, or emits C that doesn't compile.
|
|
91
|
+
fk = cfg.foreign_key.nil? ? "#{Edgeway::Model.underscore(self.class.name.split("::").last)}_id" : cfg.foreign_key.dup
|
|
92
|
+
pk = self.class.column(cfg.primary_key.nil? ? "id" : cfg.primary_key.dup)
|
|
93
|
+
Edgeway::Model::Base.model_named(name, self.class.name).where(fk => @attributes[pk])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parent_of(name)
|
|
97
|
+
return @loaded_parents[name] if @loaded_parents.key?(name)
|
|
98
|
+
cfg = nil
|
|
99
|
+
self.class.belongs_to_list.each { |b| cfg = b if b.name == name }
|
|
100
|
+
raise ArgumentError, "no belongs_to #{name}" if cfg.nil?
|
|
101
|
+
parent = Edgeway::Model::Base.model_named(name, self.class.name)
|
|
102
|
+
key = @attributes[cfg.foreign_key]
|
|
103
|
+
return parent.find(key) if cfg.primary_key == "id"
|
|
104
|
+
found = key.nil? ? nil : parent.find_by(cfg.primary_key => key)
|
|
105
|
+
raise RecordNotFound, "#{parent.table_name} #{cfg.primary_key} #{key} not found" if found.nil?
|
|
106
|
+
found
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def delete_row
|
|
112
|
+
conn = Model.connection
|
|
113
|
+
Model.write("DELETE FROM #{self.class.table_name} WHERE id = #{conn.placeholder(1)}", [id])
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def update_row
|
|
117
|
+
conn = Model.connection
|
|
118
|
+
cols = self.class.attribute_types.keys
|
|
119
|
+
values = cols.map { |c| Model.to_db(@attributes[c]) }
|
|
120
|
+
sets = cols.each_with_index.map { |c, i| "#{c} = #{conn.placeholder(i + 1)}" }.join(", ")
|
|
121
|
+
Model.write("UPDATE #{self.class.table_name} SET #{sets} WHERE id = #{conn.placeholder(cols.size + 1)}",
|
|
122
|
+
values + [id])
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def insert_row
|
|
126
|
+
raise ArgumentError, "create isn't supported inside Model.batch" if Model.batching?
|
|
127
|
+
conn = Model.connection
|
|
128
|
+
table = self.class.table_name
|
|
129
|
+
cols = self.class.attribute_types.keys.reject { |c| @attributes[c].nil? }
|
|
130
|
+
values = cols.map { |c| Model.to_db(@attributes[c]) }
|
|
131
|
+
sql = "INSERT INTO #{table}"
|
|
132
|
+
if cols.empty?
|
|
133
|
+
sql += " DEFAULT VALUES"
|
|
134
|
+
else
|
|
135
|
+
marks = cols.each_with_index.map { |_c, i| conn.placeholder(i + 1) }
|
|
136
|
+
sql += " (#{cols.join(", ")}) VALUES (#{marks.join(", ")})"
|
|
137
|
+
end
|
|
138
|
+
if conn.supports_returning?
|
|
139
|
+
load_row(conn.insert_returning(sql + " RETURNING #{self.class.columns.join(", ")}", values))
|
|
140
|
+
else
|
|
141
|
+
new_id = conn.execute(sql, values)["last_insert_id"]
|
|
142
|
+
load_row(conn.select_all("SELECT #{self.class.columns.join(", ")} FROM #{table} WHERE id = #{conn.placeholder(1)}", [new_id])[0])
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def touch(creating)
|
|
147
|
+
types = self.class.attribute_types
|
|
148
|
+
now = Model.now
|
|
149
|
+
write_attribute("created_at", now) if creating && types.key?("created_at")
|
|
150
|
+
write_attribute("updated_at", now) if types.key?("updated_at")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
module Edgeway
|
|
2
|
+
module Model
|
|
3
|
+
class Relation
|
|
4
|
+
# eager is the associations eager_load names (empty: none); records, when
|
|
5
|
+
# set, are what to_a answers instead of a query (an eager-loaded has_many).
|
|
6
|
+
attr_writer :records
|
|
7
|
+
|
|
8
|
+
def initialize(model, where_cols = [], binds = [], order = [], limit = nil, offset = nil, eager = [])
|
|
9
|
+
@model = model
|
|
10
|
+
@where_cols = where_cols
|
|
11
|
+
@binds = binds
|
|
12
|
+
@order = order
|
|
13
|
+
@limit = limit
|
|
14
|
+
@offset = offset
|
|
15
|
+
@eager = eager
|
|
16
|
+
@records = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def where(conditions)
|
|
20
|
+
cols = @where_cols.dup
|
|
21
|
+
binds = @binds.dup
|
|
22
|
+
conditions.each do |k, v|
|
|
23
|
+
col = @model.column(k)
|
|
24
|
+
casted = Model.cast(@model.type_of(col), v)
|
|
25
|
+
# An integer outside 32 bits can't be held as one; it goes as its
|
|
26
|
+
# text, which SQLite compares as the number it is, so nothing
|
|
27
|
+
# matches (a find on it is a 404).
|
|
28
|
+
casted = v.to_s if casted.nil? && !v.nil?
|
|
29
|
+
cols << col
|
|
30
|
+
binds << Model.to_db(casted)
|
|
31
|
+
end
|
|
32
|
+
Relation.new(@model, cols, binds, @order, @limit, @offset, @eager)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def order(col, dir = :asc)
|
|
36
|
+
Relation.new(@model, @where_cols, @binds, @order + [order_term(col, dir)], @limit, @offset, @eager)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Replaces all the terms.
|
|
40
|
+
def reorder(col, dir = :asc)
|
|
41
|
+
Relation.new(@model, @where_cols, @binds, [order_term(col, dir)], @limit, @offset, @eager)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def limit(n)
|
|
45
|
+
Relation.new(@model, @where_cols, @binds, @order, count_arg("limit", n), @offset, @eager)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def offset(n)
|
|
49
|
+
Relation.new(@model, @where_cols, @binds, @order, @limit, count_arg("offset", n), @eager)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Loads the has_manys and belongs_tos named of every record with the one
|
|
53
|
+
# query: a LEFT JOIN each, always (no separate preload query). The
|
|
54
|
+
# records' associations then answer from memory. Calls accumulate.
|
|
55
|
+
#
|
|
56
|
+
# Todo.eager_load(:comments, :tags).to_a.each { |t| t.comments.to_a }
|
|
57
|
+
def eager_load(*names)
|
|
58
|
+
eager = @eager.dup
|
|
59
|
+
names.each do |n|
|
|
60
|
+
eager << n.to_s unless eager.include?(n.to_s)
|
|
61
|
+
end
|
|
62
|
+
Relation.new(@model, @where_cols, @binds, @order, @limit, @offset, eager)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Same conditions, but to_a answers these records.
|
|
66
|
+
def loaded(records)
|
|
67
|
+
relation = Relation.new(@model, @where_cols, @binds, @order, @limit, @offset)
|
|
68
|
+
relation.records = records
|
|
69
|
+
relation
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def to_a
|
|
73
|
+
return @records unless @records.nil?
|
|
74
|
+
return eager_records unless @eager.empty?
|
|
75
|
+
sql, binds = select(@model.columns.join(", "), true)
|
|
76
|
+
Model.connection.select_all(sql, binds).map { |row| @model.instantiate(row) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def count
|
|
80
|
+
sql, binds = select("1", false)
|
|
81
|
+
Model.connection.select_all("SELECT COUNT(*) AS n FROM (#{sql})", binds)[0]["n"].to_i
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def exists? = limit(1).count > 0
|
|
85
|
+
|
|
86
|
+
def pluck(col)
|
|
87
|
+
name = @model.column(col)
|
|
88
|
+
type = @model.type_of(name)
|
|
89
|
+
sql, binds = select(name, true)
|
|
90
|
+
Model.connection.select_all(sql, binds).map { |row| Model.cast(type, row[name]) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def first = (@order.empty? ? order(:id) : self).limit(1).to_a.first
|
|
94
|
+
|
|
95
|
+
def delete_all
|
|
96
|
+
raise ArgumentError, "delete_all doesn't support limit/offset" if @limit || @offset
|
|
97
|
+
conn = Model.connection
|
|
98
|
+
where_sql, _marks = where_clause(conn)
|
|
99
|
+
Model.write("DELETE FROM #{@model.table_name}#{where_sql}", @binds.compact)["changes"]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def destroy_all
|
|
103
|
+
records = to_a
|
|
104
|
+
records.each { |r| r.destroy }
|
|
105
|
+
records
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def each(&block) = to_a.each(&block)
|
|
109
|
+
def map(&block) = to_a.map(&block)
|
|
110
|
+
def size = to_a.size
|
|
111
|
+
def empty? = !exists?
|
|
112
|
+
|
|
113
|
+
def find_by(conditions) = Relation.new(@model, @where_cols, @binds, @order, @limit, nil, @eager).where(conditions).limit(1).to_a.first
|
|
114
|
+
|
|
115
|
+
def find(id)
|
|
116
|
+
find_by("id" => id) || raise(RecordNotFound, "#{@model.table_name} #{id} not found")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build(attrs = {})
|
|
120
|
+
record = @model.new(attrs)
|
|
121
|
+
@where_cols.each_with_index { |col, i| record.write_attribute(col, @binds[i]) }
|
|
122
|
+
record
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def create(attrs = {})
|
|
126
|
+
record = build(attrs)
|
|
127
|
+
record.save
|
|
128
|
+
record
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def create!(attrs = {})
|
|
132
|
+
record = build(attrs)
|
|
133
|
+
record.save!
|
|
134
|
+
record
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def find_or_create_by(conditions) = find_or_create(conditions, false)
|
|
138
|
+
def find_or_create_by!(conditions) = find_or_create(conditions, true)
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# The records with @eager loaded. The conditions, order and limit apply
|
|
143
|
+
# to our own records, so they run in a subquery that each association's
|
|
144
|
+
# table is joined to. A row is one of ours with one of each association's
|
|
145
|
+
# (nil without any); joining several has_manys repeats a child once per
|
|
146
|
+
# row of the others, so a record of ours comes once however many rows it
|
|
147
|
+
# has, and each child once per record (by id).
|
|
148
|
+
def eager_records
|
|
149
|
+
mine = @model.table_name
|
|
150
|
+
many_flags = []
|
|
151
|
+
joins = []
|
|
152
|
+
cols = @model.columns.map { |c| "#{mine}.#{c} AS p_#{c}" }
|
|
153
|
+
order = @order.empty? ? ["#{mine}.id"] : @order.map { |t| "#{mine}.#{t}" }
|
|
154
|
+
@eager.each_with_index do |name, i|
|
|
155
|
+
many_flags << eager_many?(name)
|
|
156
|
+
other = Edgeway::Model::Base.model_named(name, @model.name)
|
|
157
|
+
theirs = "eager_#{name}"
|
|
158
|
+
joins << " LEFT JOIN #{other.table_name} AS #{theirs} ON #{eager_on(name, other, theirs)}"
|
|
159
|
+
cols = cols + other.columns.map { |c| "#{theirs}.#{c} AS c#{i}_#{c}" }
|
|
160
|
+
order << "#{theirs}.id"
|
|
161
|
+
end
|
|
162
|
+
inner, binds = select(@model.columns.join(", "), true)
|
|
163
|
+
sql = "SELECT #{cols.join(", ")} FROM (#{inner}) AS #{mine}#{joins.join}"
|
|
164
|
+
sql += " ORDER BY #{order.join(", ")}"
|
|
165
|
+
|
|
166
|
+
records = []
|
|
167
|
+
# children[i][j]: the records of association j of record i
|
|
168
|
+
children = []
|
|
169
|
+
position = {}
|
|
170
|
+
seen = {}
|
|
171
|
+
Model.connection.select_all(sql, binds).each do |row|
|
|
172
|
+
id = row["p_id"]
|
|
173
|
+
i = position[id]
|
|
174
|
+
if i.nil?
|
|
175
|
+
i = records.size
|
|
176
|
+
position[id] = i
|
|
177
|
+
mine_row = {}
|
|
178
|
+
@model.columns.each { |c| mine_row[c] = row["p_" + c] }
|
|
179
|
+
records << @model.instantiate(mine_row)
|
|
180
|
+
lists = []
|
|
181
|
+
@eager.each { |_n| lists << [] }
|
|
182
|
+
children << lists
|
|
183
|
+
end
|
|
184
|
+
@eager.each_with_index do |name, j|
|
|
185
|
+
cid = row["c#{j}_id"]
|
|
186
|
+
next if cid.nil?
|
|
187
|
+
key = "#{j}:#{id}:#{cid}"
|
|
188
|
+
next if seen.key?(key)
|
|
189
|
+
seen[key] = true
|
|
190
|
+
other = Edgeway::Model::Base.model_named(name, @model.name)
|
|
191
|
+
their_row = {}
|
|
192
|
+
other.columns.each { |c| their_row[c] = row["c#{j}_" + c] }
|
|
193
|
+
children[i][j] << other.instantiate(their_row)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
records.each_with_index do |record, i|
|
|
197
|
+
@eager.each_with_index do |name, j|
|
|
198
|
+
if many_flags[j]
|
|
199
|
+
record.cache_children(name, record.association(name).loaded(children[i][j]))
|
|
200
|
+
else
|
|
201
|
+
record.cache_parent(name, children[i][j][0]) unless children[i][j].empty?
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
records
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def eager_many?(name)
|
|
209
|
+
many = nil
|
|
210
|
+
@model.has_manys.each { |h| many = h if h.name == name }
|
|
211
|
+
one = nil
|
|
212
|
+
@model.belongs_to_list.each { |b| one = b if b.name == name }
|
|
213
|
+
raise ArgumentError, "no association #{name}" if many.nil? && one.nil?
|
|
214
|
+
!many.nil?
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The join condition of the association `name` (the model `other`,
|
|
218
|
+
# joined as `theirs`) to our records.
|
|
219
|
+
def eager_on(name, other, theirs)
|
|
220
|
+
mine = @model.table_name
|
|
221
|
+
many = nil
|
|
222
|
+
@model.has_manys.each { |h| many = h if h.name == name }
|
|
223
|
+
if many.nil?
|
|
224
|
+
one = nil
|
|
225
|
+
@model.belongs_to_list.each { |b| one = b if b.name == name }
|
|
226
|
+
# .dup: since matz/spinel 44119ab30 (Strings shared by reference),
|
|
227
|
+
# Spinel refuses this String as it is, or emits C that doesn't compile.
|
|
228
|
+
return "#{theirs}.#{other.column(one.primary_key.dup)} = #{mine}.#{@model.column(one.foreign_key.dup)}"
|
|
229
|
+
end
|
|
230
|
+
fk = many.foreign_key.nil? ? "#{Model.underscore(@model.name.split("::").last)}_id" : many.foreign_key
|
|
231
|
+
pk = many.primary_key.nil? ? "id" : many.primary_key
|
|
232
|
+
"#{theirs}.#{other.column(fk)} = #{mine}.#{@model.column(pk)}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def find_or_create(conditions, bang)
|
|
236
|
+
found = find_by(conditions)
|
|
237
|
+
return found unless found.nil?
|
|
238
|
+
begin
|
|
239
|
+
bang ? create!(conditions) : create(conditions)
|
|
240
|
+
rescue Edgeway::RecordNotUnique => e
|
|
241
|
+
again = find_by(conditions)
|
|
242
|
+
raise e if again.nil?
|
|
243
|
+
again
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def count_arg(what, n)
|
|
248
|
+
i = Model.to_int(n)
|
|
249
|
+
raise BadRequest, "bad #{what}: #{n}" if i.nil? || i < 0
|
|
250
|
+
i
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def order_term(col, dir)
|
|
254
|
+
d = dir.to_s.downcase
|
|
255
|
+
raise BadRequest, "bad order direction: #{dir}" unless d == "asc" || d == "desc"
|
|
256
|
+
"#{@model.column(col)} #{d.upcase}"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# " WHERE ..." (empty without conditions) and the number of placeholders
|
|
260
|
+
# it used. A nil value is IS NULL, with no bind.
|
|
261
|
+
def where_clause(conn)
|
|
262
|
+
marks = 0
|
|
263
|
+
return ["", marks] if @where_cols.empty?
|
|
264
|
+
conds = @where_cols.each_with_index.map do |c, i|
|
|
265
|
+
if @binds[i].nil?
|
|
266
|
+
"#{c} IS NULL"
|
|
267
|
+
else
|
|
268
|
+
marks += 1
|
|
269
|
+
"#{c} = #{conn.placeholder(marks)}"
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
[" WHERE " + conds.join(" AND "), marks]
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def select(what, ordered)
|
|
276
|
+
conn = Model.connection
|
|
277
|
+
where_sql, _marks = where_clause(conn)
|
|
278
|
+
sql = "SELECT #{what} FROM #{@model.table_name}#{where_sql}"
|
|
279
|
+
sql += " ORDER BY #{@order.join(", ")}" if ordered && !@order.empty?
|
|
280
|
+
binds = @binds.compact
|
|
281
|
+
if @limit
|
|
282
|
+
binds = binds + [@limit]
|
|
283
|
+
sql += " LIMIT #{conn.placeholder(binds.size)}"
|
|
284
|
+
elsif @offset
|
|
285
|
+
sql += " LIMIT -1"
|
|
286
|
+
end
|
|
287
|
+
if @offset
|
|
288
|
+
binds = binds + [@offset]
|
|
289
|
+
sql += " OFFSET #{conn.placeholder(binds.size)}"
|
|
290
|
+
end
|
|
291
|
+
[sql, binds]
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
module Edgeway
|
|
2
|
+
module Model
|
|
3
|
+
module Validations
|
|
4
|
+
def errors = @errors
|
|
5
|
+
|
|
6
|
+
def valid?
|
|
7
|
+
@errors = []
|
|
8
|
+
@raw.each do |col, raw|
|
|
9
|
+
@errors << "#{col} is out of range" if !raw.nil? && @attributes[col].nil?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
self.class.rules.each do |rule|
|
|
13
|
+
col = rule.column
|
|
14
|
+
value = rule.kind == "numericality" && @raw.key?(col) ? @raw[col] : @attributes[col]
|
|
15
|
+
msg = rule.message(value)
|
|
16
|
+
@errors << msg unless msg.nil?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
if @errors.empty?
|
|
20
|
+
self.class.belongs_to_list.each do |b|
|
|
21
|
+
fk = @attributes[b.foreign_key]
|
|
22
|
+
@errors << "#{b.name} must exist" if fk.nil? || Base.model_named(b.name, self.class.name).find_by(b.primary_key => fk).nil?
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if @errors.empty?
|
|
27
|
+
self.class.rules.each do |rule|
|
|
28
|
+
next unless rule.kind == "uniqueness"
|
|
29
|
+
@errors << "#{rule.column} has already been taken" if taken?(rule)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
@errors.empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def taken?(rule)
|
|
38
|
+
value = @attributes[rule.column]
|
|
39
|
+
return false if value.nil?
|
|
40
|
+
found = self.class.where(rule.column => value)
|
|
41
|
+
rule.scope.each { |c| found = found.where(c => @attributes[self.class.column(c)]) }
|
|
42
|
+
other = found.limit(1).to_a.first
|
|
43
|
+
!other.nil? && (new_record? || other.id != id)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class Rule
|
|
48
|
+
attr_reader :column, :kind
|
|
49
|
+
|
|
50
|
+
def self.scope_columns(options)
|
|
51
|
+
return [] if options == true
|
|
52
|
+
scope = options[:scope]
|
|
53
|
+
return [] if scope.nil?
|
|
54
|
+
scope.is_a?(Array) ? scope.map(&:to_s) : [scope.to_s]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.bounds(options)
|
|
58
|
+
h = {}
|
|
59
|
+
return h if options == true
|
|
60
|
+
options.each do |k, v|
|
|
61
|
+
key = k.to_s
|
|
62
|
+
if key == "in"
|
|
63
|
+
h["minimum"] = v.min
|
|
64
|
+
h["maximum"] = v.max
|
|
65
|
+
else
|
|
66
|
+
h[key] = v == true ? 1 : v
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
h
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def initialize(attribute, kind, bounds, list, pattern)
|
|
73
|
+
@column = attribute
|
|
74
|
+
@kind = kind
|
|
75
|
+
@bounds = bounds
|
|
76
|
+
@list = list
|
|
77
|
+
@pattern = pattern
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def message(v)
|
|
81
|
+
# Uniqueness needs a query: Validations#taken? does it.
|
|
82
|
+
return nil if @kind == "uniqueness"
|
|
83
|
+
if @kind == "presence"
|
|
84
|
+
return "#{@column} can't be blank" if v.nil? || v.to_s.strip.empty?
|
|
85
|
+
return nil
|
|
86
|
+
end
|
|
87
|
+
return nil if v.nil?
|
|
88
|
+
case @kind
|
|
89
|
+
when "length" then length_message(v.to_s.length)
|
|
90
|
+
when "inclusion" then @list.include?(v.to_s) ? nil : "#{@column} is not included in the list"
|
|
91
|
+
when "format" then @pattern.match?(v.to_s) ? nil : "#{@column} is invalid"
|
|
92
|
+
when "numericality" then numericality_message(v)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def scope = @list
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def length_message(n)
|
|
101
|
+
if @bounds.key?("minimum") && n < @bounds["minimum"]
|
|
102
|
+
return "#{@column} is too short (minimum is #{@bounds["minimum"]} characters)"
|
|
103
|
+
end
|
|
104
|
+
if @bounds.key?("maximum") && n > @bounds["maximum"]
|
|
105
|
+
return "#{@column} is too long (maximum is #{@bounds["maximum"]} characters)"
|
|
106
|
+
end
|
|
107
|
+
if @bounds.key?("is") && n != @bounds["is"]
|
|
108
|
+
return "#{@column} is the wrong length (should be #{@bounds["is"]} characters)"
|
|
109
|
+
end
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def numericality_message(v)
|
|
114
|
+
text = v.to_s
|
|
115
|
+
integer = text.match?(/\A[+-]?\d+\z/)
|
|
116
|
+
# Decimals and exponents too: Float#to_s writes 1.0e+20 and 1.0e-05.
|
|
117
|
+
return "#{@column} is not a number" unless integer || text.match?(/\A[+-]?\d+(\.\d+)?([eE][+-]?\d+)?\z/)
|
|
118
|
+
return "#{@column} must be an integer" if @bounds.key?("only_integer") && !integer
|
|
119
|
+
n = text.to_f
|
|
120
|
+
if @bounds.key?("greater_than") && !(n > @bounds["greater_than"])
|
|
121
|
+
return "#{@column} must be greater than #{@bounds["greater_than"]}"
|
|
122
|
+
end
|
|
123
|
+
if @bounds.key?("greater_than_or_equal_to") && !(n >= @bounds["greater_than_or_equal_to"])
|
|
124
|
+
return "#{@column} must be greater than or equal to #{@bounds["greater_than_or_equal_to"]}"
|
|
125
|
+
end
|
|
126
|
+
if @bounds.key?("less_than") && !(n < @bounds["less_than"])
|
|
127
|
+
return "#{@column} must be less than #{@bounds["less_than"]}"
|
|
128
|
+
end
|
|
129
|
+
if @bounds.key?("less_than_or_equal_to") && !(n <= @bounds["less_than_or_equal_to"])
|
|
130
|
+
return "#{@column} must be less than or equal to #{@bounds["less_than_or_equal_to"]}"
|
|
131
|
+
end
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Edgeway
|
|
2
|
+
module Routing
|
|
3
|
+
class Mapper
|
|
4
|
+
ACTIONS = ["index", "show", "create", "update", "destroy"]
|
|
5
|
+
|
|
6
|
+
def initialize(routes, path_prefix, module_prefix)
|
|
7
|
+
@routes = routes
|
|
8
|
+
@path_prefix = path_prefix
|
|
9
|
+
@module_prefix = module_prefix
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def get(path, to:) = add("GET", path, to)
|
|
13
|
+
def post(path, to:) = add("POST", path, to)
|
|
14
|
+
def patch(path, to:) = add("PATCH", path, to)
|
|
15
|
+
def put(path, to:) = add("PUT", path, to)
|
|
16
|
+
def delete(path, to:) = add("DELETE", path, to)
|
|
17
|
+
|
|
18
|
+
def root(to:) = add("GET", "/", to)
|
|
19
|
+
|
|
20
|
+
def namespace(name)
|
|
21
|
+
yield Mapper.new(@routes, "#{@path_prefix}/#{name}", "#{@module_prefix}#{name}/")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def resources(name, only: [], except: [])
|
|
25
|
+
base = "/#{name}"
|
|
26
|
+
member = "#{base}/:id"
|
|
27
|
+
(only + except).each do |a|
|
|
28
|
+
raise ArgumentError, "unknown action for resources :#{name}: #{a}" unless ACTIONS.include?(a.to_s)
|
|
29
|
+
end
|
|
30
|
+
want = ACTIONS.dup
|
|
31
|
+
want = only.map(&:to_s) unless only.empty?
|
|
32
|
+
want = want.reject { |a| except.map(&:to_s).include?(a) }
|
|
33
|
+
|
|
34
|
+
add("GET", base, "#{name}#index") if want.include?("index")
|
|
35
|
+
add("POST", base, "#{name}#create") if want.include?("create")
|
|
36
|
+
add("GET", member, "#{name}#show") if want.include?("show")
|
|
37
|
+
if want.include?("update")
|
|
38
|
+
add("PATCH", member, "#{name}#update")
|
|
39
|
+
add("PUT", member, "#{name}#update")
|
|
40
|
+
end
|
|
41
|
+
add("DELETE", member, "#{name}#destroy") if want.include?("destroy")
|
|
42
|
+
if block_given?
|
|
43
|
+
yield Mapper.new(@routes, "#{@path_prefix}#{base}/:#{Mapper.singular(name)}_id", @module_prefix)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.singular(name) = Inflector.singularize(name)
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def add(verb, path, to)
|
|
52
|
+
controller, action = to.split("#", 2)
|
|
53
|
+
@routes.add(Route.new(verb, @path_prefix + path, @module_prefix + controller, action))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Edgeway
|
|
2
|
+
module Routing
|
|
3
|
+
class Route
|
|
4
|
+
attr_reader :verb, :pattern, :controller_path, :action
|
|
5
|
+
|
|
6
|
+
def initialize(verb, pattern, controller_path, action)
|
|
7
|
+
@verb = verb
|
|
8
|
+
@pattern = pattern
|
|
9
|
+
@segments = Route.split(pattern)
|
|
10
|
+
@controller_path = controller_path
|
|
11
|
+
@action = action
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.split(path) = path.split("/").reject(&:empty?)
|
|
15
|
+
|
|
16
|
+
def controller_class_name
|
|
17
|
+
parts = @controller_path.split("/").map do |part|
|
|
18
|
+
part.split("_").map(&:capitalize).join
|
|
19
|
+
end
|
|
20
|
+
parts.join("::") + "Controller"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def match(verb, path)
|
|
24
|
+
return nil unless verb == @verb || (verb == "HEAD" && @verb == "GET")
|
|
25
|
+
parts = Route.split(path)
|
|
26
|
+
return nil unless parts.size == @segments.size
|
|
27
|
+
found = {}
|
|
28
|
+
@segments.each_with_index do |seg, i|
|
|
29
|
+
if seg.start_with?(":")
|
|
30
|
+
found[seg[1..]] = parts[i]
|
|
31
|
+
elsif seg != parts[i]
|
|
32
|
+
return nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
found
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|