fresco 0.0.1
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/exe/fresco +3 -0
- data/lib/fresco/application.rb +12 -0
- data/lib/fresco/cli/build.rb +682 -0
- data/lib/fresco/cli/dev.rb +17 -0
- data/lib/fresco/cli/dev_loop.rb +815 -0
- data/lib/fresco/cli/new.rb +120 -0
- data/lib/fresco/cli/release.rb +76 -0
- data/lib/fresco/cli.rb +56 -0
- data/lib/fresco/database_config.rb +34 -0
- data/lib/fresco/generators/app/Gemfile.tt +18 -0
- data/lib/fresco/generators/app/README.md.tt +32 -0
- data/lib/fresco/generators/app/app/action.rb.tt +20 -0
- data/lib/fresco/generators/app/app/actions/root_path.rb.tt +5 -0
- data/lib/fresco/generators/app/app/views/layouts/application.html.erb +29 -0
- data/lib/fresco/generators/app/app/views/root_path.html.erb +8 -0
- data/lib/fresco/generators/app/app.rb.tt +15 -0
- data/lib/fresco/generators/app/bin/build +2 -0
- data/lib/fresco/generators/app/bin/dev +2 -0
- data/lib/fresco/generators/app/bin/release +2 -0
- data/lib/fresco/generators/app/config/app.rb.tt +26 -0
- data/lib/fresco/generators/app/config/database.rb +17 -0
- data/lib/fresco/generators/app/config/routes.rb +11 -0
- data/lib/fresco/generators/app/db/schema.rb +14 -0
- data/lib/fresco/generators/app/public/404.html +87 -0
- data/lib/fresco/generators/app/public/500.html +84 -0
- data/lib/fresco/migration_builder.rb +55 -0
- data/lib/fresco/model_builder.rb +54 -0
- data/lib/fresco/paths.rb +20 -0
- data/lib/fresco/router.rb +67 -0
- data/lib/fresco/runtime/boot.rb +34 -0
- data/lib/fresco/runtime/db_postgres.rb +403 -0
- data/lib/fresco/runtime/db_sqlite.rb +495 -0
- data/lib/fresco/runtime/http.c +456 -0
- data/lib/fresco/runtime/postgres.c +339 -0
- data/lib/fresco/runtime/runtime.rb +1810 -0
- data/lib/fresco/runtime/sqlite.c +220 -0
- data/lib/fresco/runtime/welcome.rb +152 -0
- data/lib/fresco/schema_builder.rb +71 -0
- data/lib/fresco/templates/dispatch.rb.erb +32 -0
- data/lib/fresco/templates/layout_dispatch.rb.erb +16 -0
- data/lib/fresco/templates/manifest.rb.erb +5 -0
- data/lib/fresco/templates/migrations.rb.erb +152 -0
- data/lib/fresco/templates/model.rb.erb +223 -0
- data/lib/fresco/templates/view.rb.erb +5 -0
- data/lib/fresco/version.rb +3 -0
- data/lib/fresco.rb +61 -0
- metadata +115 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
# Fresco: codegen step. Reads config/routes.rb + app/actions/ +
|
|
2
|
+
# app/views/ from the user app's cwd, emits
|
|
3
|
+
# generated/{boot,runtime,dispatch,requires,views}.rb (plus per-view
|
|
4
|
+
# files under generated/views/) deterministically.
|
|
5
|
+
|
|
6
|
+
require "erb"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
require "herb"
|
|
9
|
+
require "herb/engine"
|
|
10
|
+
|
|
11
|
+
require "fresco"
|
|
12
|
+
|
|
13
|
+
module Fresco
|
|
14
|
+
class CLI
|
|
15
|
+
class Build
|
|
16
|
+
GEN_DIR = "generated".freeze
|
|
17
|
+
VIEWS_GEN_DIR = "#{GEN_DIR}/views".freeze
|
|
18
|
+
MODELS_GEN_DIR = "#{GEN_DIR}/models".freeze
|
|
19
|
+
# C shims get copied here so the `ffi_cflags "generated/runtime/*.c"`
|
|
20
|
+
# strings in the runtime templates resolve from the user app's cwd.
|
|
21
|
+
RUNTIME_GEN_DIR = "#{GEN_DIR}/runtime".freeze
|
|
22
|
+
|
|
23
|
+
# In-gem source roots (set once at load — Fresco::Paths resolves
|
|
24
|
+
# relative to lib/fresco/paths.rb, which is stable per-install).
|
|
25
|
+
TEMPLATES_DIR = Fresco::Paths.template_root
|
|
26
|
+
RUNTIME_DIR = Fresco::Paths.runtime_root
|
|
27
|
+
|
|
28
|
+
NAME_CHAR = /[A-Za-z0-9_]/.freeze
|
|
29
|
+
|
|
30
|
+
# Partial render call rewrite — see resolve_partial / rewrite_partial_renders.
|
|
31
|
+
PARTIAL_RENDER_RE = /<%=\s*render(?:\s*\(\s*|\s+)partial:\s*["']([^"']+)["'](?:\s*,\s*locals:\s*\{(.*?)\})?\s*\)?\s*%>/m
|
|
32
|
+
|
|
33
|
+
# Fresco-supplied kwargs every layout receives.
|
|
34
|
+
LAYOUT_FRAMEWORK_KWARGS = "flash: Flash.new, content: \"\"".freeze
|
|
35
|
+
|
|
36
|
+
LOCALS_RE = /\A\s*<%#\s*locals:\s*(\(.*?\))\s*%>\s*\r?\n?/.freeze
|
|
37
|
+
LAYOUT_YIELD_RE = /<%=\s*yield\s*%>/.freeze
|
|
38
|
+
|
|
39
|
+
STATIC_APPEND_PREFIX = "_buf << '".freeze
|
|
40
|
+
STATIC_APPEND_SUFFIX = "'.freeze".freeze
|
|
41
|
+
MAX_VIEW_LITERAL_BYTES = 512
|
|
42
|
+
|
|
43
|
+
def run(argv = [])
|
|
44
|
+
FileUtils.mkdir_p(GEN_DIR)
|
|
45
|
+
FileUtils.mkdir_p(VIEWS_GEN_DIR)
|
|
46
|
+
FileUtils.mkdir_p(RUNTIME_GEN_DIR)
|
|
47
|
+
|
|
48
|
+
lint_actions!
|
|
49
|
+
action_files = stub_action_classes
|
|
50
|
+
route_entries = load_routes
|
|
51
|
+
has_root = route_entries.any? { |verb, pattern, _, _| verb == "GET" && pattern == "/" }
|
|
52
|
+
|
|
53
|
+
emit_welcome(has_root, route_entries)
|
|
54
|
+
emit_dispatch(route_entries)
|
|
55
|
+
emit_runtime
|
|
56
|
+
emit_c_shims
|
|
57
|
+
db_adapter = emit_db_adapter
|
|
58
|
+
emit_models(db_adapter)
|
|
59
|
+
generated_models = collect_generated_models
|
|
60
|
+
emit_migrations(db_adapter)
|
|
61
|
+
emit_requires_manifest(action_files, generated_models, has_root)
|
|
62
|
+
emit_boot
|
|
63
|
+
view_names, layout_names = emit_views
|
|
64
|
+
emit_layout_dispatch(layout_names)
|
|
65
|
+
|
|
66
|
+
puts "wrote #{GEN_DIR}/{boot,dispatch,layout_dispatch,requires,runtime,views}.rb " \
|
|
67
|
+
"(#{route_entries.size} routes, #{view_names.size} views, #{layout_names.size} layouts)"
|
|
68
|
+
0
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def render_template(name, **locals)
|
|
74
|
+
src = File.read(File.join(TEMPLATES_DIR, name))
|
|
75
|
+
ERB.new(src, trim_mode: "-").result_with_hash(locals)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Lint user actions against the Spinel-acceptable Ruby subset before
|
|
79
|
+
# touching generated/. Anything the rubocop_spinel plugin flags is
|
|
80
|
+
# a hard build failure — generated output from un-compileable input
|
|
81
|
+
# would just push the error downstream into Spinel itself.
|
|
82
|
+
#
|
|
83
|
+
# Skip silently when rubocop_spinel isn't on the user's bundle (it's
|
|
84
|
+
# the recommended setup but not a hard requirement; freshly-scaffolded
|
|
85
|
+
# apps run their first build before `bundle install` adds it).
|
|
86
|
+
def lint_actions!
|
|
87
|
+
return unless File.exist?("Gemfile.lock") && File.read("Gemfile.lock").include?("rubocop_spinel")
|
|
88
|
+
unless system("bundle", "exec", "rubocop", "--no-color", "app/")
|
|
89
|
+
abort "[build] rubocop_spinel: violations in app/; aborting."
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Action stubs let config/routes.rb resolve `to: SomeAction` constants
|
|
94
|
+
# without actually loading user code (which would need runtime.rb).
|
|
95
|
+
def stub_action_classes
|
|
96
|
+
files = Dir.glob("app/actions/**/*.rb").sort
|
|
97
|
+
files.each do |file|
|
|
98
|
+
name = Fresco.path_to_class(file, "app/actions")
|
|
99
|
+
Fresco.const_set_path(name, Class.new)
|
|
100
|
+
end
|
|
101
|
+
files
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def load_routes
|
|
105
|
+
load "config/routes.rb"
|
|
106
|
+
Fresco.app.routes.entries
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# --- Default welcome route fallback ------------------------------
|
|
110
|
+
def emit_welcome(has_root, route_entries)
|
|
111
|
+
welcome_path = "#{GEN_DIR}/welcome.rb"
|
|
112
|
+
if has_root
|
|
113
|
+
# Stale welcome from a prior build (when the user had no root) —
|
|
114
|
+
# clear it so dev doesn't load a Fresco::Welcome nothing references.
|
|
115
|
+
File.delete(welcome_path) if File.exist?(welcome_path)
|
|
116
|
+
else
|
|
117
|
+
FileUtils.cp("#{RUNTIME_DIR}/welcome.rb", welcome_path)
|
|
118
|
+
Fresco.const_set_path("Fresco::Welcome", Class.new)
|
|
119
|
+
route_entries.unshift(["GET", "/", Fresco.const_get_path("Fresco::Welcome"), {}])
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# --- Pattern → regex source + capture names -----------------------
|
|
124
|
+
#
|
|
125
|
+
# Char-by-char scan. Tokens:
|
|
126
|
+
# :name → named capture; default [^/]+, or constraints[:name].source
|
|
127
|
+
# *name → splat capture; matches .+ (incl. slashes)
|
|
128
|
+
# (...) → optional group, compiled as (?:...)? (may nest)
|
|
129
|
+
# / → literal slash, emitted as \/
|
|
130
|
+
# anything else → Regexp.escape
|
|
131
|
+
#
|
|
132
|
+
# For optional captures, the dispatcher emits `req.params[:name] = $N || ""`
|
|
133
|
+
# so CRuby (dev mode) and Spinel (release) both store "" when un-matched —
|
|
134
|
+
# Spinel already coerces NULL $N to "", but CRuby leaves it nil.
|
|
135
|
+
def compile_pattern(pattern, constraints = {})
|
|
136
|
+
captures = []
|
|
137
|
+
out = String.new('\A')
|
|
138
|
+
paren = 0
|
|
139
|
+
i = 0
|
|
140
|
+
n = pattern.length
|
|
141
|
+
|
|
142
|
+
while i < n
|
|
143
|
+
c = pattern[i]
|
|
144
|
+
case c
|
|
145
|
+
when "("
|
|
146
|
+
out << "(?:"
|
|
147
|
+
paren += 1
|
|
148
|
+
i += 1
|
|
149
|
+
when ")"
|
|
150
|
+
raise "unbalanced ')' in #{pattern.inspect}" if paren.zero?
|
|
151
|
+
out << ")?"
|
|
152
|
+
paren -= 1
|
|
153
|
+
i += 1
|
|
154
|
+
when "/"
|
|
155
|
+
out << '\/'
|
|
156
|
+
i += 1
|
|
157
|
+
when ":"
|
|
158
|
+
i += 1
|
|
159
|
+
name_start = i
|
|
160
|
+
i += 1 while i < n && NAME_CHAR.match?(pattern[i])
|
|
161
|
+
raise "empty :param name in #{pattern.inspect}" if i == name_start
|
|
162
|
+
name = pattern[name_start...i].to_sym
|
|
163
|
+
captures << name
|
|
164
|
+
out << (constraints.key?(name) ? "(#{constraints[name].source})" : '([^\/]+)')
|
|
165
|
+
when "*"
|
|
166
|
+
i += 1
|
|
167
|
+
name_start = i
|
|
168
|
+
i += 1 while i < n && NAME_CHAR.match?(pattern[i])
|
|
169
|
+
raise "empty *splat name in #{pattern.inspect}" if i == name_start
|
|
170
|
+
captures << pattern[name_start...i].to_sym
|
|
171
|
+
out << '(.+)'
|
|
172
|
+
else
|
|
173
|
+
out << Regexp.escape(c)
|
|
174
|
+
i += 1
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
raise "unclosed '(' in #{pattern.inspect}" unless paren.zero?
|
|
179
|
+
|
|
180
|
+
out << '\z'
|
|
181
|
+
[out, captures]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# --- Emit dispatch.rb --------------------------------------------
|
|
185
|
+
def emit_dispatch(route_entries)
|
|
186
|
+
compiled_routes = route_entries.map do |verb, pattern, klass, constraints|
|
|
187
|
+
regex_src, captures = compile_pattern(pattern, constraints)
|
|
188
|
+
{ verb: verb, pattern: pattern, klass: klass, regex_src: regex_src, captures: captures }
|
|
189
|
+
end
|
|
190
|
+
File.write("#{GEN_DIR}/dispatch.rb", render_template("dispatch.rb.erb", routes: compiled_routes))
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# --- Emit runtime.rb (copied from the gem's runtime/) ------------
|
|
194
|
+
def emit_runtime
|
|
195
|
+
FileUtils.cp("#{RUNTIME_DIR}/runtime.rb", "#{GEN_DIR}/runtime.rb")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# --- Copy C shims into generated/runtime/ ------------------------
|
|
199
|
+
#
|
|
200
|
+
# runtime.rb / db_*.rb reference these via `ffi_cflags
|
|
201
|
+
# "generated/runtime/<name>.c"`. The strings are evaluated by
|
|
202
|
+
# Spinel from the user app's cwd, so the .c files have to live
|
|
203
|
+
# there — they ship in the gem and get copied per-build.
|
|
204
|
+
def emit_c_shims
|
|
205
|
+
%w[http.c sqlite.c postgres.c].each do |c|
|
|
206
|
+
FileUtils.cp("#{RUNTIME_DIR}/#{c}", "#{RUNTIME_GEN_DIR}/#{c}")
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# --- Emit db_adapter.rb (per-adapter template) -------------------
|
|
211
|
+
def emit_db_adapter
|
|
212
|
+
db_adapter = :none
|
|
213
|
+
if File.exist?("config/database.rb")
|
|
214
|
+
load "config/database.rb"
|
|
215
|
+
db_adapter = Fresco.database_adapter if Fresco.database_adapter
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
case db_adapter
|
|
219
|
+
when :sqlite
|
|
220
|
+
FileUtils.cp("#{RUNTIME_DIR}/db_sqlite.rb", "#{GEN_DIR}/db_adapter.rb")
|
|
221
|
+
when :postgres
|
|
222
|
+
FileUtils.cp("#{RUNTIME_DIR}/db_postgres.rb", "#{GEN_DIR}/db_adapter.rb")
|
|
223
|
+
when :none
|
|
224
|
+
# Even with no adapter, runtime.rb's request handler still calls
|
|
225
|
+
# `Fresco::Db.begin_request!` / `flush_log!`. Define them as no-ops.
|
|
226
|
+
File.write(
|
|
227
|
+
"#{GEN_DIR}/db_adapter.rb",
|
|
228
|
+
<<~RUBY,
|
|
229
|
+
# Fresco: no DB adapter configured. Auto-generated by `fresco build`.
|
|
230
|
+
# Add config/database.rb with `Fresco.database :sqlite` (or :postgres)
|
|
231
|
+
# to enable Fresco::Db::Active.
|
|
232
|
+
module Fresco
|
|
233
|
+
module Db
|
|
234
|
+
def self.begin_request!; end
|
|
235
|
+
def self.flush_log!; end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
RUBY
|
|
239
|
+
)
|
|
240
|
+
else
|
|
241
|
+
abort "[build] unknown adapter #{db_adapter.inspect} in config/database.rb " \
|
|
242
|
+
"(expected :sqlite or :postgres)"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
unless File.exist?("config/database.rb")
|
|
246
|
+
FileUtils.mkdir_p("config")
|
|
247
|
+
File.write(
|
|
248
|
+
"config/database.rb",
|
|
249
|
+
"# Fresco: no DB configured. Replace with `Fresco.database :sqlite, " \
|
|
250
|
+
"path: ENV.fetch(\"DATABASE_PATH\", \"db/app.sqlite3\")` (or :postgres) " \
|
|
251
|
+
"when you need one.\n",
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
db_adapter
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# --- Emit generated models (per-adapter SQL baked in) ------------
|
|
259
|
+
def emit_models(db_adapter)
|
|
260
|
+
FileUtils.mkdir_p(MODELS_GEN_DIR)
|
|
261
|
+
Dir.glob("#{MODELS_GEN_DIR}/*.rb").each { |f| File.delete(f) }
|
|
262
|
+
|
|
263
|
+
# Schema + model + migration source files. Loaded under CRuby at
|
|
264
|
+
# build time — captured DSL state is read back below to drive
|
|
265
|
+
# codegen. Migrations need to load whether or not the user has
|
|
266
|
+
# models (e.g. early apps that haven't declared models yet still
|
|
267
|
+
# want db:migrate to work).
|
|
268
|
+
if File.exist?("db/schema.rb") && Dir.glob("app/models/*.rb").any?
|
|
269
|
+
load "db/schema.rb"
|
|
270
|
+
Dir.glob("app/models/*.rb").sort.each { |f| load f }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
Fresco.models.each do |model|
|
|
274
|
+
write_model_file(model, db_adapter)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def collect_generated_models
|
|
279
|
+
Dir.glob("#{MODELS_GEN_DIR}/*.rb").sort.map { |f| File.basename(f, ".rb") }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def write_model_file(model, db_adapter)
|
|
283
|
+
table = Fresco.schema_tables.find { |t| t[:name] == model[:table] }
|
|
284
|
+
unless table
|
|
285
|
+
abort "[build] Fresco.model :#{model[:name]} references table :#{model[:table]} " \
|
|
286
|
+
"but db/schema.rb doesn't declare it"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
columns = table[:columns]
|
|
290
|
+
pk_col = columns.find { |c| c[:primary_key] }
|
|
291
|
+
unless pk_col
|
|
292
|
+
abort "[build] table :#{table[:name]} has no primary_key column; " \
|
|
293
|
+
"declare one with `column :id, :int, primary_key: true`"
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
data_cols = columns.reject { |c| c[:primary_key] }
|
|
297
|
+
|
|
298
|
+
# `find(id = 0)` — positional ctor with defaulted PK. The default
|
|
299
|
+
# acts as the type pin for Spinel ([[spinel_initialize_kwargs]],
|
|
300
|
+
# [[spinel_param_type_collapse]]).
|
|
301
|
+
ctor_params = columns.map { |c| "#{c[:name]} = #{col_zero(c)}" }.join(", ") +
|
|
302
|
+
", found = false"
|
|
303
|
+
|
|
304
|
+
pk_param = "#{pk_col[:name]} = 0"
|
|
305
|
+
|
|
306
|
+
row_ctor_args = columns.each_with_index.map { |c, i| col_read_expr(c, i, db_adapter) }.join(", ")
|
|
307
|
+
|
|
308
|
+
table_name = table[:name].to_s
|
|
309
|
+
|
|
310
|
+
select_by_pk_sql = "SELECT #{join_cols(columns)} FROM #{table_name} WHERE #{pk_col[:name]} = " \
|
|
311
|
+
"#{db_adapter == :postgres ? '$1' : '?'}"
|
|
312
|
+
select_all_sql = "SELECT #{join_cols(columns)} FROM #{table_name} ORDER BY #{pk_col[:name]}"
|
|
313
|
+
|
|
314
|
+
finders = model[:finders].map do |fname|
|
|
315
|
+
col = columns.find { |c| c[:name] == fname }
|
|
316
|
+
abort "[build] finder :#{fname} on :#{model[:name]} but no such column" unless col
|
|
317
|
+
{
|
|
318
|
+
name: fname,
|
|
319
|
+
sql: "SELECT #{join_cols(columns)} FROM #{table_name} WHERE #{fname} = " \
|
|
320
|
+
"#{db_adapter == :postgres ? '$1' : '?'}",
|
|
321
|
+
count_sql: "SELECT COUNT(*) FROM #{table_name} WHERE #{fname} = " \
|
|
322
|
+
"#{db_adapter == :postgres ? '$1' : '?'}",
|
|
323
|
+
bind_call: col_finder_bind_call(col),
|
|
324
|
+
default: col_zero(col),
|
|
325
|
+
unique: col[:index] == :unique,
|
|
326
|
+
}
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
count_sql = "SELECT COUNT(*) FROM #{table_name}"
|
|
330
|
+
|
|
331
|
+
validators = (model[:validators] || []).map do |v|
|
|
332
|
+
col = columns.find { |c| c[:name] == v[:column] }
|
|
333
|
+
abort "[build] validates :#{v[:column]} on :#{model[:name]} but no such column" unless col
|
|
334
|
+
case v[:rule]
|
|
335
|
+
when :presence
|
|
336
|
+
guard = case col[:type]
|
|
337
|
+
when :str then "#{col[:name]}.length == 0"
|
|
338
|
+
when :int then "#{col[:name]} == 0"
|
|
339
|
+
when :bool then "#{col[:name]} == false"
|
|
340
|
+
end
|
|
341
|
+
{ column: v[:column], guard: guard, message: "#{col[:name]} required" }
|
|
342
|
+
else
|
|
343
|
+
abort "[build] unknown validation rule #{v[:rule].inspect} for :#{model[:name]}"
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
insert_bind_lines = data_cols.each_with_index.map { |c, i| col_bind_stmt(c, i + 1) }
|
|
348
|
+
insert_kwargs = data_cols.map { |c| "#{c[:name]}: #{col_zero(c)}" }.join(", ")
|
|
349
|
+
insert_kwarg_sig = data_cols.map { |c| "#{c[:name]}:" }.join(", ")
|
|
350
|
+
|
|
351
|
+
insert_sql = if db_adapter == :postgres
|
|
352
|
+
"INSERT INTO #{table_name} (#{join_cols(data_cols)}) VALUES (#{placeholders(data_cols.length, :postgres)}) RETURNING #{pk_col[:name]}"
|
|
353
|
+
else
|
|
354
|
+
"INSERT INTO #{table_name} (#{join_cols(data_cols)}) VALUES (#{placeholders(data_cols.length, :sqlite)})"
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
update_bind_lines = data_cols.each_with_index.map { |c, i| col_bind_stmt(c, i + 1) }
|
|
358
|
+
update_kwargs = data_cols.map { |c| "#{c[:name]}: #{col_zero(c)}" }.join(", ")
|
|
359
|
+
update_kwarg_sig = data_cols.map { |c| "#{c[:name]}:" }.join(", ")
|
|
360
|
+
pk_placeholder = db_adapter == :postgres ? "$#{data_cols.length + 1}" : "?"
|
|
361
|
+
update_sql = "UPDATE #{table_name} SET #{assignments(data_cols, db_adapter, 1)} WHERE #{pk_col[:name]} = #{pk_placeholder}"
|
|
362
|
+
|
|
363
|
+
delete_sql = "DELETE FROM #{table_name} WHERE #{pk_col[:name]} = " \
|
|
364
|
+
"#{db_adapter == :postgres ? '$1' : '?'}"
|
|
365
|
+
|
|
366
|
+
file_stem = model[:name].to_s.gsub(/([a-z0-9])([A-Z])/, '\1_\2').downcase
|
|
367
|
+
|
|
368
|
+
File.write(
|
|
369
|
+
"#{MODELS_GEN_DIR}/#{file_stem}.rb",
|
|
370
|
+
render_template("model.rb.erb",
|
|
371
|
+
model_name: model[:name].to_s,
|
|
372
|
+
file_stem: file_stem,
|
|
373
|
+
table_name: table_name,
|
|
374
|
+
adapter: db_adapter,
|
|
375
|
+
columns: columns,
|
|
376
|
+
pk_name: pk_col[:name],
|
|
377
|
+
pk_param: pk_param,
|
|
378
|
+
ctor_params: ctor_params,
|
|
379
|
+
row_ctor_args: row_ctor_args,
|
|
380
|
+
select_by_pk_sql: select_by_pk_sql,
|
|
381
|
+
select_all_sql: select_all_sql,
|
|
382
|
+
finders: finders,
|
|
383
|
+
count_sql: count_sql,
|
|
384
|
+
validators: validators,
|
|
385
|
+
insert_bind_lines: insert_bind_lines,
|
|
386
|
+
insert_kwargs: insert_kwargs,
|
|
387
|
+
insert_kwarg_signature: insert_kwarg_sig,
|
|
388
|
+
insert_sql: insert_sql,
|
|
389
|
+
update_bind_lines: update_bind_lines,
|
|
390
|
+
update_kwargs: update_kwargs,
|
|
391
|
+
update_kwarg_signature: update_kwarg_sig,
|
|
392
|
+
update_sql: update_sql,
|
|
393
|
+
delete_sql: delete_sql,
|
|
394
|
+
),
|
|
395
|
+
)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Per-column-type codegen helpers — see bin/build comment block in
|
|
399
|
+
# the pre-gem layout for the full rationale (col_str rotating-buffer
|
|
400
|
+
# quirk, bool adapter divergence, etc.).
|
|
401
|
+
def col_read_expr(col, idx, adapter)
|
|
402
|
+
case col[:type]
|
|
403
|
+
when :int then "Fresco::Db.col_int(cid, #{idx})"
|
|
404
|
+
when :str then "(Fresco::Db.col_str(cid, #{idx}) + \"\")"
|
|
405
|
+
when :bool
|
|
406
|
+
if adapter == :postgres
|
|
407
|
+
"(Fresco::Db.col_str(cid, #{idx}) == \"t\")"
|
|
408
|
+
else
|
|
409
|
+
"(Fresco::Db.col_int(cid, #{idx}) != 0)"
|
|
410
|
+
end
|
|
411
|
+
else abort "[build] unknown column type #{col[:type].inspect} on #{col[:name]}"
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def col_bind_stmt(col, idx)
|
|
416
|
+
nm = col[:name].to_s.inspect
|
|
417
|
+
case col[:type]
|
|
418
|
+
when :int then "Fresco::Db.bind_int(cid, #{idx}, #{col[:name]}, #{nm})"
|
|
419
|
+
when :str then "Fresco::Db.bind_str(cid, #{idx}, #{col[:name]}, #{nm})"
|
|
420
|
+
when :bool then "Fresco::Db.bind_int(cid, #{idx}, #{col[:name]} ? 1 : 0, #{nm})"
|
|
421
|
+
else abort "[build] unknown column type #{col[:type].inspect} on #{col[:name]}"
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def col_zero(col)
|
|
426
|
+
case col[:type]
|
|
427
|
+
when :int then "0"
|
|
428
|
+
when :str then '""'
|
|
429
|
+
when :bool then "false"
|
|
430
|
+
else abort "[build] unknown column type #{col[:type].inspect} on #{col[:name]}"
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def col_finder_bind_call(col)
|
|
435
|
+
nm = col[:name].to_s.inspect
|
|
436
|
+
case col[:type]
|
|
437
|
+
when :int then "Fresco::Db.bind_int(cid, 1, #{col[:name]}, #{nm})"
|
|
438
|
+
when :str then "Fresco::Db.bind_str(cid, 1, #{col[:name]}, #{nm})"
|
|
439
|
+
when :bool then "Fresco::Db.bind_int(cid, 1, #{col[:name]} ? 1 : 0, #{nm})"
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def join_cols(columns)
|
|
444
|
+
columns.map { |c| c[:name].to_s }.join(", ")
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def placeholders(n, adapter)
|
|
448
|
+
if adapter == :postgres
|
|
449
|
+
(1..n).map { |i| "$#{i}" }.join(", ")
|
|
450
|
+
else
|
|
451
|
+
Array.new(n, "?").join(", ")
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def assignments(columns, adapter, start_idx = 1)
|
|
456
|
+
columns.map.with_index do |c, i|
|
|
457
|
+
ph = adapter == :postgres ? "$#{start_idx + i}" : "?"
|
|
458
|
+
"#{c[:name]} = #{ph}"
|
|
459
|
+
end.join(", ")
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# --- Emit migrations.rb (embedded SQL + runner) ------------------
|
|
463
|
+
def emit_migrations(db_adapter)
|
|
464
|
+
Dir.glob("db/migrations/*.rb").sort.each { |f| load f }
|
|
465
|
+
|
|
466
|
+
migrations_data = Fresco.migrations.map do |m|
|
|
467
|
+
suffix = m[:name].to_s.gsub(/[^A-Za-z0-9_]/, "_")
|
|
468
|
+
{ name: m[:name], method_suffix: suffix, up_sql: m[:up_sql], down_sql: m[:down_sql] }
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
pl_q = db_adapter == :postgres ? "$1" : "?"
|
|
472
|
+
|
|
473
|
+
File.write(
|
|
474
|
+
"#{GEN_DIR}/migrations.rb",
|
|
475
|
+
render_template("migrations.rb.erb",
|
|
476
|
+
migrations: migrations_data,
|
|
477
|
+
applied_sql: "SELECT COUNT(*) FROM schema_migrations WHERE name = #{pl_q}",
|
|
478
|
+
insert_applied_sql: "INSERT INTO schema_migrations (name) VALUES (#{pl_q})",
|
|
479
|
+
delete_applied_sql: "DELETE FROM schema_migrations WHERE name = #{pl_q}",
|
|
480
|
+
),
|
|
481
|
+
)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# --- Emit requires.rb (model + action manifest) ------------------
|
|
485
|
+
def emit_requires_manifest(action_files, generated_models, has_root)
|
|
486
|
+
model_require_paths = generated_models.map { |stem| "models/#{stem}" }
|
|
487
|
+
action_paths = model_require_paths + ["../app/action"] + action_files.map do |f|
|
|
488
|
+
f.sub(%r{\Aapp/actions/}, "../app/actions/").sub(/\.rb\z/, "")
|
|
489
|
+
end
|
|
490
|
+
action_paths.unshift("welcome") unless has_root
|
|
491
|
+
File.write("#{GEN_DIR}/requires.rb", render_template("manifest.rb.erb", kind: "action", paths: action_paths))
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# --- Emit boot.rb ------------------------------------------------
|
|
495
|
+
def emit_boot
|
|
496
|
+
FileUtils.cp("#{RUNTIME_DIR}/boot.rb", "#{GEN_DIR}/boot.rb")
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# --- Emit view modules (Herb::Engine → render_<name>) ------------
|
|
500
|
+
def emit_views
|
|
501
|
+
view_files = Dir.glob("app/views/**/*.html.erb").sort
|
|
502
|
+
Dir.glob("#{VIEWS_GEN_DIR}/*.rb").each { |f| File.delete(f) }
|
|
503
|
+
|
|
504
|
+
view_names = []
|
|
505
|
+
layout_names = []
|
|
506
|
+
view_files.each do |path|
|
|
507
|
+
rel = path.sub(%r{\Aapp/views/}, "")
|
|
508
|
+
source = File.read(path)
|
|
509
|
+
|
|
510
|
+
signature = +""
|
|
511
|
+
if (m = source.match(LOCALS_RE))
|
|
512
|
+
signature = m[1]
|
|
513
|
+
source = source.sub(LOCALS_RE, "")
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
if layout_file?(rel)
|
|
517
|
+
source = source.gsub(LAYOUT_YIELD_RE, "<%== content %>")
|
|
518
|
+
signature = inject_layout_kwargs(signature)
|
|
519
|
+
layout_names << layout_name_from(rel)
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
source = rewrite_partial_renders(source, rel)
|
|
523
|
+
|
|
524
|
+
src = Herb::Engine.new(
|
|
525
|
+
source,
|
|
526
|
+
filename: path,
|
|
527
|
+
escape: true,
|
|
528
|
+
escapefunc: "::Spinel::View.h",
|
|
529
|
+
attrfunc: "::Spinel::View.attr",
|
|
530
|
+
jsfunc: "::Spinel::View.js",
|
|
531
|
+
cssfunc: "::Spinel::View.css",
|
|
532
|
+
).src
|
|
533
|
+
src = split_static_view_literals(src)
|
|
534
|
+
|
|
535
|
+
name = view_method_name(rel)
|
|
536
|
+
File.write(
|
|
537
|
+
"#{VIEWS_GEN_DIR}/#{name}.rb",
|
|
538
|
+
render_template("view.rb.erb", rel: rel, name: name, signature: signature, src: src),
|
|
539
|
+
)
|
|
540
|
+
view_names << name
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
view_paths = view_names.map { |n| "views/#{n}" }
|
|
544
|
+
File.write("#{GEN_DIR}/views.rb", render_template("manifest.rb.erb", kind: "view", paths: view_paths))
|
|
545
|
+
|
|
546
|
+
[view_names, layout_names]
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def view_method_name(rel)
|
|
550
|
+
rel.sub(/\.html\.erb\z/, "").tr("/", "_")
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# Build-time rewrite of `<%= render partial: "name", locals: {...} %>`
|
|
554
|
+
# to a typed direct call. Spinel's poly-hash collapse means we can't
|
|
555
|
+
# ship the locals hash to runtime — splice as kwargs instead.
|
|
556
|
+
def resolve_partial(name, source_rel)
|
|
557
|
+
candidates =
|
|
558
|
+
if name.include?("/")
|
|
559
|
+
parts = name.split("/")
|
|
560
|
+
parts[-1] = "_#{parts[-1]}"
|
|
561
|
+
["#{parts.join('/')}.html.erb"]
|
|
562
|
+
else
|
|
563
|
+
list = []
|
|
564
|
+
folder = File.dirname(source_rel)
|
|
565
|
+
loop do
|
|
566
|
+
if folder == "."
|
|
567
|
+
list << "_#{name}.html.erb"
|
|
568
|
+
break
|
|
569
|
+
end
|
|
570
|
+
list << "#{folder}/_#{name}.html.erb"
|
|
571
|
+
folder = File.dirname(folder)
|
|
572
|
+
end
|
|
573
|
+
list
|
|
574
|
+
end
|
|
575
|
+
resolved = candidates.find { |c| File.exist?("app/views/#{c}") }
|
|
576
|
+
unless resolved
|
|
577
|
+
abort "[build] partial #{name.inspect} not found from app/views/#{source_rel} " \
|
|
578
|
+
"(tried: #{candidates.map { |c| "app/views/#{c}" }.join(", ")})"
|
|
579
|
+
end
|
|
580
|
+
view_method_name(resolved)
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def rewrite_partial_renders(source, source_rel)
|
|
584
|
+
source.gsub(PARTIAL_RENDER_RE) do
|
|
585
|
+
method_name = "render_#{resolve_partial($1, source_rel)}"
|
|
586
|
+
locals_body = ($2 || "").strip
|
|
587
|
+
"<%== #{method_name}(#{locals_body}) %>"
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def layout_file?(rel)
|
|
592
|
+
rel.start_with?("layouts/")
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def layout_name_from(rel)
|
|
596
|
+
rel.sub(%r{\Alayouts/}, "").sub(/\.html\.erb\z/, "").tr("/", "_")
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def inject_layout_kwargs(sig)
|
|
600
|
+
return "(#{LAYOUT_FRAMEWORK_KWARGS})" if sig.empty?
|
|
601
|
+
sig.sub(/\)\z/, ", #{LAYOUT_FRAMEWORK_KWARGS})")
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def odd_backslashes_before?(src, idx)
|
|
605
|
+
count = 0
|
|
606
|
+
i = idx - 1
|
|
607
|
+
while i >= 0 && src[i] == "\\"
|
|
608
|
+
count += 1
|
|
609
|
+
i -= 1
|
|
610
|
+
end
|
|
611
|
+
count.odd?
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def find_single_quote_end(src, start)
|
|
615
|
+
i = start
|
|
616
|
+
while i < src.length
|
|
617
|
+
return i if src[i] == "'" && !odd_backslashes_before?(src, i)
|
|
618
|
+
i += 1
|
|
619
|
+
end
|
|
620
|
+
raise "[build] unterminated Herb string literal in generated view source"
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def trailing_backslashes_odd?(src)
|
|
624
|
+
count = 0
|
|
625
|
+
i = src.length - 1
|
|
626
|
+
while i >= 0 && src[i] == "\\"
|
|
627
|
+
count += 1
|
|
628
|
+
i -= 1
|
|
629
|
+
end
|
|
630
|
+
count.odd?
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def split_single_quoted_content(content)
|
|
634
|
+
return [content] if content.length <= MAX_VIEW_LITERAL_BYTES
|
|
635
|
+
|
|
636
|
+
chunks = []
|
|
637
|
+
i = 0
|
|
638
|
+
while i < content.length
|
|
639
|
+
j = [i + MAX_VIEW_LITERAL_BYTES, content.length].min
|
|
640
|
+
if j < content.length
|
|
641
|
+
j -= 1 while j > i && trailing_backslashes_odd?(content[i...j])
|
|
642
|
+
j = [i + MAX_VIEW_LITERAL_BYTES, content.length].min if j == i
|
|
643
|
+
end
|
|
644
|
+
chunks << content[i...j]
|
|
645
|
+
i = j
|
|
646
|
+
end
|
|
647
|
+
chunks
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def split_static_view_literals(src)
|
|
651
|
+
out = +""
|
|
652
|
+
i = 0
|
|
653
|
+
while (pos = src.index(STATIC_APPEND_PREFIX, i))
|
|
654
|
+
out << src[i...pos]
|
|
655
|
+
content_start = pos + STATIC_APPEND_PREFIX.length
|
|
656
|
+
quote_end = find_single_quote_end(src, content_start)
|
|
657
|
+
after_quote = quote_end + 1
|
|
658
|
+
|
|
659
|
+
unless src[after_quote, ".freeze".length] == ".freeze"
|
|
660
|
+
out << src[pos...after_quote]
|
|
661
|
+
i = after_quote
|
|
662
|
+
next
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
chunks = split_single_quoted_content(src[content_start...quote_end])
|
|
666
|
+
out << chunks.map { |chunk| "#{STATIC_APPEND_PREFIX}#{chunk}#{STATIC_APPEND_SUFFIX}" }.join("; ")
|
|
667
|
+
i = after_quote + ".freeze".length
|
|
668
|
+
end
|
|
669
|
+
out << src[i..] if i < src.length
|
|
670
|
+
out
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
# --- Emit layout_dispatch.rb -------------------------------------
|
|
674
|
+
def emit_layout_dispatch(layout_names)
|
|
675
|
+
File.write(
|
|
676
|
+
"#{GEN_DIR}/layout_dispatch.rb",
|
|
677
|
+
render_template("layout_dispatch.rb.erb", layout_names: layout_names),
|
|
678
|
+
)
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
end
|
|
682
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Fresco
|
|
2
|
+
class CLI
|
|
3
|
+
class Dev
|
|
4
|
+
# The dev loop relies on top-level method and constant definitions
|
|
5
|
+
# in the loaded generated/runtime.rb (Request, Response, Sock,
|
|
6
|
+
# dispatch_request, Color, parse_query_string!, …). Keeping the
|
|
7
|
+
# loop itself as a top-level script preserves that scoping —
|
|
8
|
+
# methods defined in dev_loop.rb live in the same Object scope as
|
|
9
|
+
# the things `load "generated/runtime.rb"` defines.
|
|
10
|
+
def run(argv)
|
|
11
|
+
ARGV.replace(argv)
|
|
12
|
+
Kernel.load(File.expand_path("dev_loop.rb", __dir__))
|
|
13
|
+
0
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|