cybertrain 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/README.md +461 -0
- data/cybertrain/cli/new_app.rb +75 -0
- data/cybertrain/cli/scaffold.rb +227 -0
- data/cybertrain/cli/templates.rb +421 -0
- data/cybertrain/cli.rb +100 -0
- data/cybertrain/generator/inflector.rb +126 -0
- data/cybertrain/version.rb +8 -0
- data/exe/cybertrain +7 -0
- metadata +53 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# `cybertrain generate scaffold post title:string body:text`: writes the
|
|
2
|
+
# migration, model, controller and views of a resource and adds
|
|
3
|
+
# `resources :posts` to config/routes.rb, the way Rails' scaffold does.
|
|
4
|
+
require "cybertrain/generator/inflector"
|
|
5
|
+
require "cybertrain/cli/templates"
|
|
6
|
+
|
|
7
|
+
module Cybertrain
|
|
8
|
+
module CLI
|
|
9
|
+
class InvalidArgument < StandardError
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# One "name:type" argument. A references field ("post:references")
|
|
13
|
+
# becomes the post_id column.
|
|
14
|
+
class Field
|
|
15
|
+
TYPES = %w[string text integer float boolean date datetime references]
|
|
16
|
+
|
|
17
|
+
# Columns every table already has (the primary key and t.timestamps).
|
|
18
|
+
RESERVED_COLUMNS = %w[id created_at updated_at]
|
|
19
|
+
|
|
20
|
+
# Ruby keywords: a column (or resource) named after one would generate
|
|
21
|
+
# `def class` / `|end|` and break the generated code.
|
|
22
|
+
RUBY_KEYWORDS = %w[
|
|
23
|
+
__ENCODING__ __LINE__ __FILE__ BEGIN END alias and begin break case
|
|
24
|
+
class def defined do else elsif end ensure false for if in module
|
|
25
|
+
next nil not or redo rescue retry return self super then true undef
|
|
26
|
+
unless until when while yield
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
attr_reader :field_name, :field_type
|
|
30
|
+
|
|
31
|
+
def self.parse(arg)
|
|
32
|
+
colons = 0
|
|
33
|
+
arg.each_char { |ch| colons += 1 if ch == ":" }
|
|
34
|
+
raise InvalidArgument, "bad field '#{arg}': use name:type (modifiers such as :index are not supported)" if colons > 1
|
|
35
|
+
|
|
36
|
+
parts = arg.split(":")
|
|
37
|
+
name = parts[0].to_s
|
|
38
|
+
type = parts.size > 1 ? parts[1].to_s : "string"
|
|
39
|
+
type = "references" if type == "belongs_to"
|
|
40
|
+
raise InvalidArgument, "bad field name '#{name}'" unless Templates.identifier?(name)
|
|
41
|
+
raise InvalidArgument, "'#{name}' is a Ruby keyword and cannot name a field" if RUBY_KEYWORDS.include?(name)
|
|
42
|
+
raise InvalidArgument, "unknown type '#{type}' for #{name} (use #{TYPES.join(", ")})" unless TYPES.include?(type)
|
|
43
|
+
|
|
44
|
+
field = Field.new(name, type)
|
|
45
|
+
raise InvalidArgument, "'#{field.column_name}' is a column every table already has" if RESERVED_COLUMNS.include?(field.column_name)
|
|
46
|
+
|
|
47
|
+
field
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def initialize(field_name, field_type)
|
|
51
|
+
@field_name = field_name
|
|
52
|
+
@field_type = field_type
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def reference?
|
|
56
|
+
@field_type == "references"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def column_name
|
|
60
|
+
reference? ? "#{@field_name}_id" : @field_name
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def label
|
|
64
|
+
Templates.humanize(@field_name)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def migration_line
|
|
68
|
+
reference? ? "t.references :#{@field_name}" : "t.#{@field_type} \"#{@field_name}\""
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The FormBuilder method that edits this column.
|
|
72
|
+
def form_input
|
|
73
|
+
case @field_type
|
|
74
|
+
when "text" then "text_area"
|
|
75
|
+
when "integer", "float" then "number_field"
|
|
76
|
+
when "boolean" then "check_box"
|
|
77
|
+
else "text_field"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The names a scaffold derives from its NAME argument ("post", "Post"
|
|
83
|
+
# or "posts" all give post / posts / Post / Posts).
|
|
84
|
+
class Resource
|
|
85
|
+
attr_reader :singular, :plural, :class_name, :plural_class, :fields
|
|
86
|
+
|
|
87
|
+
def initialize(name, fields)
|
|
88
|
+
@singular = Inflector.singularize(Inflector.underscore(name))
|
|
89
|
+
@plural = Inflector.pluralize(@singular)
|
|
90
|
+
@class_name = Inflector.camelize(@singular)
|
|
91
|
+
@plural_class = Inflector.camelize(@plural)
|
|
92
|
+
@fields = fields
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The column the model validates for presence ("" when there is none).
|
|
96
|
+
def first_string_field
|
|
97
|
+
@fields.each { |f| return f.field_name if f.field_type == "string" }
|
|
98
|
+
""
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
module Scaffold
|
|
103
|
+
DRAW_LINE = "Cybertrain::Routes.draw do"
|
|
104
|
+
|
|
105
|
+
# Returns the paths it created (files that already exist are reported
|
|
106
|
+
# as "identical" or "exist" and left alone). Raises InvalidArgument on a
|
|
107
|
+
# bad name, a bad or duplicate field, or a routes file without a
|
|
108
|
+
# `Cybertrain::Routes.draw do` line, before anything is written.
|
|
109
|
+
def self.generate(root, name, fields)
|
|
110
|
+
routes_path = File.join(root, "config/routes.rb")
|
|
111
|
+
raise InvalidArgument, "#{routes_path} not found: run this inside a cybertrain app" unless File.exist?(routes_path)
|
|
112
|
+
raise InvalidArgument, "#{routes_path} has no `#{DRAW_LINE}` line" if File.read(routes_path).index(DRAW_LINE).nil?
|
|
113
|
+
|
|
114
|
+
underscored = Inflector.underscore(name)
|
|
115
|
+
raise InvalidArgument, "bad resource name '#{name}'" unless Templates.identifier?(underscored)
|
|
116
|
+
raise InvalidArgument, "'#{name}' is a Ruby keyword and cannot name a resource" if Field::RUBY_KEYWORDS.include?(Inflector.singularize(underscored))
|
|
117
|
+
|
|
118
|
+
parsed = Array.new(0) { Field.new("", "") }
|
|
119
|
+
columns = Array.new(0) { "" }
|
|
120
|
+
fields.each do |arg|
|
|
121
|
+
field = Field.parse(arg)
|
|
122
|
+
raise InvalidArgument, "duplicate column '#{field.column_name}'" if columns.include?(field.column_name)
|
|
123
|
+
|
|
124
|
+
columns << field.column_name
|
|
125
|
+
parsed << field
|
|
126
|
+
end
|
|
127
|
+
res = Resource.new(name, parsed)
|
|
128
|
+
|
|
129
|
+
created = Array.new(0) { "" }
|
|
130
|
+
migration = migration_path(root, res)
|
|
131
|
+
record(created, root, migration, Templates.migration(res))
|
|
132
|
+
record(created, root, "app/models/#{res.singular}.rb", Templates.model(res))
|
|
133
|
+
record(created, root, "app/controllers/#{res.plural}_controller.rb", Templates.controller(res))
|
|
134
|
+
views = "app/views/#{res.plural}"
|
|
135
|
+
record(created, root, "#{views}/index.html.erb", Templates.index_view(res))
|
|
136
|
+
record(created, root, "#{views}/show.html.erb", Templates.show_view(res))
|
|
137
|
+
record(created, root, "#{views}/new.html.erb", Templates.new_view(res))
|
|
138
|
+
record(created, root, "#{views}/edit.html.erb", Templates.edit_view(res))
|
|
139
|
+
record(created, root, "#{views}/_form.html.erb", Templates.form_partial(res))
|
|
140
|
+
add_route(routes_path, res.plural)
|
|
141
|
+
created
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def self.record(created, root, path, content)
|
|
145
|
+
created << path if Templates.write(root, path, content) == "create"
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# db/migrate/<timestamp>_create_posts.rb, or the existing
|
|
150
|
+
# *_create_posts.rb when the scaffold already ran.
|
|
151
|
+
def self.migration_path(root, res)
|
|
152
|
+
suffix = "_create_#{res.plural}.rb"
|
|
153
|
+
existing = Dir.glob(File.join(root, "db/migrate/*#{suffix}")).sort
|
|
154
|
+
return "db/migrate/#{File.basename(existing[0])}" unless existing.empty?
|
|
155
|
+
|
|
156
|
+
"db/migrate/#{next_version(root, timestamp)}#{suffix}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The version a new migration gets: stamp, unless db/migrate already
|
|
160
|
+
# holds a version >= stamp (two scaffolds in the same second, or a
|
|
161
|
+
# fixed CYBERTRAIN_TIMESTAMP), in which case the largest one + 1, the
|
|
162
|
+
# way Rails does it. Versions must stay unique (schema_migrations keys
|
|
163
|
+
# on them) and increasing (they order the migrations).
|
|
164
|
+
def self.next_version(root, stamp)
|
|
165
|
+
largest = 0
|
|
166
|
+
Dir.glob(File.join(root, "db/migrate/*.rb")).each do |path|
|
|
167
|
+
digits = leading_digits(File.basename(path))
|
|
168
|
+
next if digits == ""
|
|
169
|
+
|
|
170
|
+
v = digits.to_i
|
|
171
|
+
largest = v if v > largest
|
|
172
|
+
end
|
|
173
|
+
return stamp if stamp.to_i > largest
|
|
174
|
+
|
|
175
|
+
(largest + 1).to_s
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def self.leading_digits(name)
|
|
179
|
+
out = +""
|
|
180
|
+
name.each_char do |ch|
|
|
181
|
+
break unless ch >= "0" && ch <= "9"
|
|
182
|
+
|
|
183
|
+
out << ch
|
|
184
|
+
end
|
|
185
|
+
out
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def self.timestamp
|
|
189
|
+
fixed = ENV["CYBERTRAIN_TIMESTAMP"]
|
|
190
|
+
return fixed.to_s unless fixed.nil? || fixed == ""
|
|
191
|
+
|
|
192
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Inserts ` resources :posts` right after `Cybertrain::Routes.draw do`
|
|
196
|
+
# unless a `resources :posts` line is already there.
|
|
197
|
+
def self.add_route(routes_path, plural)
|
|
198
|
+
route = "resources :#{plural}"
|
|
199
|
+
source = File.read(routes_path)
|
|
200
|
+
lines = source.split("\n")
|
|
201
|
+
present = false
|
|
202
|
+
lines.each do |line|
|
|
203
|
+
l = line.strip
|
|
204
|
+
present = true if l == route || l.start_with?("#{route} ") || l.start_with?("#{route},")
|
|
205
|
+
end
|
|
206
|
+
if present
|
|
207
|
+
puts "identical route #{route}"
|
|
208
|
+
return false
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
at = source.index(DRAW_LINE)
|
|
212
|
+
raise InvalidArgument, "#{routes_path} has no `#{DRAW_LINE}` line" if at.nil?
|
|
213
|
+
|
|
214
|
+
eol = source.index("\n", at)
|
|
215
|
+
eol = source.size if eol.nil?
|
|
216
|
+
updated = +""
|
|
217
|
+
updated << source[0, eol]
|
|
218
|
+
updated << "\n #{route}"
|
|
219
|
+
updated << source[eol, source.size - eol]
|
|
220
|
+
updated << "\n" unless updated.end_with?("\n")
|
|
221
|
+
File.write(routes_path, updated)
|
|
222
|
+
puts "route #{route}"
|
|
223
|
+
true
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
# Cybertrain::CLI::Templates -- the file contents `cybertrain new` and
|
|
2
|
+
# `cybertrain generate scaffold` write, embedded in the binary, plus the one
|
|
3
|
+
# helper that puts them on disk.
|
|
4
|
+
#
|
|
5
|
+
# The scaffold templates take a Cybertrain::CLI::Resource (cli/scaffold.rb).
|
|
6
|
+
# Generated views stay inside the template language of docs/design.md
|
|
7
|
+
# section 7: literals, @ivars, locals, helper calls, `if` and `each do`.
|
|
8
|
+
module Cybertrain
|
|
9
|
+
module CLI
|
|
10
|
+
module Templates
|
|
11
|
+
# Writes root/path unless it already exists, creating directories on
|
|
12
|
+
# the way. Returns "create", "identical" (same content already there)
|
|
13
|
+
# or "exist" (different content: left alone, never overwritten), and
|
|
14
|
+
# prints that status with the path.
|
|
15
|
+
def self.write(root, path, content)
|
|
16
|
+
full = File.join(root, path)
|
|
17
|
+
status = "create"
|
|
18
|
+
if File.exist?(full)
|
|
19
|
+
status = File.read(full) == content ? "identical" : "exist"
|
|
20
|
+
else
|
|
21
|
+
mkdir_p(File.dirname(full))
|
|
22
|
+
File.write(full, content)
|
|
23
|
+
end
|
|
24
|
+
puts "#{status} #{path}"
|
|
25
|
+
status
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.mkdir_p(dir)
|
|
29
|
+
return if dir == "" || File.directory?(dir)
|
|
30
|
+
|
|
31
|
+
path = dir.start_with?("/") ? +"/" : +""
|
|
32
|
+
dir.split("/").each do |part|
|
|
33
|
+
next if part == ""
|
|
34
|
+
|
|
35
|
+
path << part
|
|
36
|
+
Dir.mkdir(path) unless File.directory?(path)
|
|
37
|
+
path << "/"
|
|
38
|
+
end
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A lowercase Ruby-ish name: "post", "blog_post", "released_on".
|
|
43
|
+
def self.identifier?(word)
|
|
44
|
+
return false if word == ""
|
|
45
|
+
|
|
46
|
+
ok = true
|
|
47
|
+
first = true
|
|
48
|
+
word.each_char do |ch|
|
|
49
|
+
lower = ch >= "a" && ch <= "z"
|
|
50
|
+
digit = ch >= "0" && ch <= "9"
|
|
51
|
+
ok = false unless lower || (!first && (digit || ch == "_"))
|
|
52
|
+
first = false
|
|
53
|
+
end
|
|
54
|
+
ok
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# "blog_posts" -> "Blog posts"
|
|
58
|
+
def self.humanize(word)
|
|
59
|
+
word.tr("_", " ").capitalize
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# ---- cybertrain new ----------------------------------------------
|
|
63
|
+
|
|
64
|
+
# framework_dep is the TOML value: `{ path = "..." }` or `"~> 0.1"`.
|
|
65
|
+
def self.spin_toml(package, framework_dep)
|
|
66
|
+
<<~TOML
|
|
67
|
+
[package]
|
|
68
|
+
name = "#{package}"
|
|
69
|
+
version = "0.1.0"
|
|
70
|
+
|
|
71
|
+
[dependencies]
|
|
72
|
+
cybertrain = #{framework_dep}
|
|
73
|
+
TOML
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.gitignore
|
|
77
|
+
<<~TEXT
|
|
78
|
+
/build/
|
|
79
|
+
/storage/*.sqlite3*
|
|
80
|
+
/tmp/*
|
|
81
|
+
!/tmp/.keep
|
|
82
|
+
/log/
|
|
83
|
+
TEXT
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.readme(title)
|
|
87
|
+
<<~MARKDOWN
|
|
88
|
+
# #{title}
|
|
89
|
+
|
|
90
|
+
A cybertrain application, compiled to one binary by Spinel.
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
cybertrain generate scaffold post title:string body:text
|
|
94
|
+
spin run gen # pick up the new migration (gen/migrations.rb)
|
|
95
|
+
spin run db -- migrate # apply db/migrate, rewrite db/schema.rb
|
|
96
|
+
spin run gen # regenerate gen/ from db/schema.rb, config/routes.rb and app/
|
|
97
|
+
spin run server # http://127.0.0.1:3000
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Run `spin run gen` after changing the schema, the routes or a
|
|
101
|
+
controller's instance variables and callbacks, and commit `gen/`.
|
|
102
|
+
Views under `app/views/` are read at run time: edit them without
|
|
103
|
+
rebuilding.
|
|
104
|
+
MARKDOWN
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.config_app
|
|
108
|
+
<<~RUBY
|
|
109
|
+
# Application settings; see Cybertrain::Config for every option.
|
|
110
|
+
# Environment variables (PORT, CYBERTRAIN_ENV, CYBERTRAIN_DATABASE,
|
|
111
|
+
# CYBERTRAIN_SECRET_KEY_BASE) are read before this block runs.
|
|
112
|
+
Cybertrain.configure do |c|
|
|
113
|
+
# c.port = 3000
|
|
114
|
+
# c.workers = 1
|
|
115
|
+
|
|
116
|
+
# Production marks the session cookie Secure (HTTPS only). Turn it
|
|
117
|
+
# off only if production is really served over plain http://.
|
|
118
|
+
# c.session_secure = false
|
|
119
|
+
end
|
|
120
|
+
RUBY
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.routes
|
|
124
|
+
"Cybertrain::Routes.draw do\nend\n"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.schema
|
|
128
|
+
"Cybertrain::Schema.define(version: \"0\") do |s|\nend\n"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.application_controller
|
|
132
|
+
<<~RUBY
|
|
133
|
+
class ApplicationController < Cybertrain::Controller
|
|
134
|
+
rescue_from Cybertrain::RecordNotFound, with: :record_not_found
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def record_not_found
|
|
139
|
+
render plain: "Not Found", status: :not_found
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
RUBY
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.layout(title)
|
|
146
|
+
<<~ERB
|
|
147
|
+
<!DOCTYPE html>
|
|
148
|
+
<html>
|
|
149
|
+
<head>
|
|
150
|
+
<title>#{title}</title>
|
|
151
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
152
|
+
<%= csrf_meta_tags %>
|
|
153
|
+
<link rel="stylesheet" href="/style.css">
|
|
154
|
+
</head>
|
|
155
|
+
<body>
|
|
156
|
+
<main>
|
|
157
|
+
<% if flash[:notice] %>
|
|
158
|
+
<p class="notice"><%= flash[:notice] %></p>
|
|
159
|
+
<% end %>
|
|
160
|
+
<% if flash[:alert] %>
|
|
161
|
+
<p class="alert"><%= flash[:alert] %></p>
|
|
162
|
+
<% end %>
|
|
163
|
+
<%= yield %>
|
|
164
|
+
</main>
|
|
165
|
+
</body>
|
|
166
|
+
</html>
|
|
167
|
+
ERB
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def self.error_page(status, message)
|
|
171
|
+
<<~HTML
|
|
172
|
+
<!DOCTYPE html>
|
|
173
|
+
<html>
|
|
174
|
+
<head>
|
|
175
|
+
<title>#{message} (#{status})</title>
|
|
176
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
177
|
+
<link rel="stylesheet" href="/style.css">
|
|
178
|
+
</head>
|
|
179
|
+
<body>
|
|
180
|
+
<main>
|
|
181
|
+
<h1>#{message}</h1>
|
|
182
|
+
<p>#{status == "404" ? "The page you were looking for doesn't exist." : "Something went wrong on our side."}</p>
|
|
183
|
+
</main>
|
|
184
|
+
</body>
|
|
185
|
+
</html>
|
|
186
|
+
HTML
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def self.style_css
|
|
190
|
+
<<~CSS
|
|
191
|
+
body { font-family: system-ui, sans-serif; line-height: 1.5; margin: 0; color: #222; }
|
|
192
|
+
main { max-width: 48rem; margin: 0 auto; padding: 1rem; }
|
|
193
|
+
a { color: #0b5cad; }
|
|
194
|
+
table { border-collapse: collapse; width: 100%; }
|
|
195
|
+
th, td { text-align: left; padding: 0.25rem 0.5rem; border-bottom: 1px solid #ddd; }
|
|
196
|
+
.notice { color: #166534; background: #dcfce7; padding: 0.5rem; }
|
|
197
|
+
.alert { color: #991b1b; background: #fee2e2; padding: 0.5rem; }
|
|
198
|
+
.field { margin-bottom: 0.75rem; }
|
|
199
|
+
.field label { display: block; font-weight: 600; }
|
|
200
|
+
.field input[type=text], .field input[type=number], .field textarea { width: 100%; padding: 0.25rem; }
|
|
201
|
+
.field_with_errors input, .field_with_errors textarea { border: 1px solid #b91c1c; }
|
|
202
|
+
#error_explanation { color: #991b1b; border: 1px solid #fca5a5; padding: 0.5rem 1rem; margin-bottom: 1rem; }
|
|
203
|
+
form.button_to { display: inline; }
|
|
204
|
+
CSS
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def self.bin_server
|
|
208
|
+
<<~RUBY
|
|
209
|
+
require "cybertrain"
|
|
210
|
+
require_relative "../config/app"
|
|
211
|
+
require_relative "../gen/app"
|
|
212
|
+
|
|
213
|
+
app = Cybertrain::Application.new(
|
|
214
|
+
router: Gen::Routes.build(Cybertrain::Router.new),
|
|
215
|
+
url_resolver: Gen::Routes.url_resolver
|
|
216
|
+
)
|
|
217
|
+
app.run
|
|
218
|
+
RUBY
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def self.bin_gen
|
|
222
|
+
<<~RUBY
|
|
223
|
+
require "cybertrain/generator"
|
|
224
|
+
require_relative "../config/routes"
|
|
225
|
+
require_relative "../db/schema"
|
|
226
|
+
|
|
227
|
+
exit(Cybertrain::Gen::Runner.run(".", ARGV))
|
|
228
|
+
RUBY
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def self.bin_db
|
|
232
|
+
<<~RUBY
|
|
233
|
+
require "cybertrain"
|
|
234
|
+
require_relative "../config/app"
|
|
235
|
+
require_relative "../gen/migrations"
|
|
236
|
+
|
|
237
|
+
exit(Cybertrain::DB::CLI.run(ARGV))
|
|
238
|
+
RUBY
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# ---- cybertrain generate scaffold --------------------------------
|
|
242
|
+
|
|
243
|
+
def self.migration(res)
|
|
244
|
+
buf = +""
|
|
245
|
+
buf << "class Create#{res.plural_class} < Cybertrain::Migration::Base\n"
|
|
246
|
+
buf << " def change\n"
|
|
247
|
+
buf << " create_table \"#{res.plural}\" do |t|\n"
|
|
248
|
+
res.fields.each { |f| buf << " #{f.migration_line}\n" }
|
|
249
|
+
buf << " t.timestamps\n"
|
|
250
|
+
buf << " end\n"
|
|
251
|
+
buf << " end\n"
|
|
252
|
+
buf << "end\n"
|
|
253
|
+
buf
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def self.model(res)
|
|
257
|
+
buf = +""
|
|
258
|
+
buf << "# Columns, associations and the Cybertrain::Model superclass come from\n"
|
|
259
|
+
buf << "# gen/models/#{res.singular}.rb, generated from db/schema.rb by `spin run gen`.\n"
|
|
260
|
+
buf << "class #{res.class_name}\n"
|
|
261
|
+
first = res.first_string_field
|
|
262
|
+
buf << " validates :#{first}, presence: true\n" unless first == ""
|
|
263
|
+
buf << "end\n"
|
|
264
|
+
buf
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def self.controller(res)
|
|
268
|
+
s = res.singular
|
|
269
|
+
p = res.plural
|
|
270
|
+
human = humanize(s)
|
|
271
|
+
permitted = res.fields.map { |f| ":#{f.column_name}" }.join(", ")
|
|
272
|
+
<<~RUBY
|
|
273
|
+
class #{res.plural_class}Controller < ApplicationController
|
|
274
|
+
before_action :set_#{s}, only: [:show, :edit, :update, :destroy]
|
|
275
|
+
|
|
276
|
+
# GET /#{p}
|
|
277
|
+
def index
|
|
278
|
+
@#{p} = #{res.class_name}.all.to_a
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# GET /#{p}/1
|
|
282
|
+
def show
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# GET /#{p}/new
|
|
286
|
+
# (named new_action: a `new` method would shadow #{res.plural_class}Controller.new)
|
|
287
|
+
def new_action
|
|
288
|
+
@#{s} = #{res.class_name}.new
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# GET /#{p}/1/edit
|
|
292
|
+
def edit
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# POST /#{p}
|
|
296
|
+
def create
|
|
297
|
+
@#{s} = #{res.class_name}.new(#{s}_params)
|
|
298
|
+
if @#{s}.save
|
|
299
|
+
flash[:notice] = "#{human} was successfully created."
|
|
300
|
+
redirect_to #{s}_path(@#{s}), status: :see_other
|
|
301
|
+
else
|
|
302
|
+
render :new, status: :unprocessable_entity
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# PATCH/PUT /#{p}/1
|
|
307
|
+
def update
|
|
308
|
+
if @#{s}.update(#{s}_params)
|
|
309
|
+
flash[:notice] = "#{human} was successfully updated."
|
|
310
|
+
redirect_to #{s}_path(@#{s}), status: :see_other
|
|
311
|
+
else
|
|
312
|
+
render :edit, status: :unprocessable_entity
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# DELETE /#{p}/1
|
|
317
|
+
def destroy
|
|
318
|
+
@#{s}.destroy
|
|
319
|
+
flash[:notice] = "#{human} was successfully destroyed."
|
|
320
|
+
redirect_to #{p}_path, status: :see_other
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
private
|
|
324
|
+
|
|
325
|
+
def set_#{s}
|
|
326
|
+
@#{s} = #{res.class_name}.find(params[:id])
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def #{s}_params
|
|
330
|
+
params.require(:#{s}).permit(#{permitted})
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
RUBY
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def self.index_view(res)
|
|
337
|
+
s = res.singular
|
|
338
|
+
words = s.tr("_", " ")
|
|
339
|
+
buf = +""
|
|
340
|
+
buf << "<h1>#{humanize(res.plural)}</h1>\n\n"
|
|
341
|
+
buf << "<table>\n <thead>\n <tr>\n"
|
|
342
|
+
res.fields.each { |f| buf << " <th>#{f.label}</th>\n" }
|
|
343
|
+
buf << " <th></th>\n </tr>\n </thead>\n <tbody>\n"
|
|
344
|
+
buf << " <% @#{res.plural}.each do |#{s}| %>\n"
|
|
345
|
+
buf << " <tr>\n"
|
|
346
|
+
res.fields.each { |f| buf << " <td><%= #{s}.#{f.column_name} %></td>\n" }
|
|
347
|
+
buf << " <td><%= link_to \"Show\", #{s}_path(#{s}) %></td>\n"
|
|
348
|
+
buf << " </tr>\n"
|
|
349
|
+
buf << " <% end %>\n"
|
|
350
|
+
buf << " </tbody>\n</table>\n\n"
|
|
351
|
+
buf << "<p><%= link_to \"New #{words}\", new_#{s}_path %></p>\n"
|
|
352
|
+
buf
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def self.show_view(res)
|
|
356
|
+
s = res.singular
|
|
357
|
+
words = s.tr("_", " ")
|
|
358
|
+
buf = +""
|
|
359
|
+
buf << "<h1>#{humanize(s)}</h1>\n\n"
|
|
360
|
+
res.fields.each do |f|
|
|
361
|
+
buf << "<p>\n <strong>#{f.label}:</strong>\n <%= @#{s}.#{f.column_name} %>\n</p>\n\n"
|
|
362
|
+
end
|
|
363
|
+
buf << "<p>\n"
|
|
364
|
+
buf << " <%= link_to \"Edit this #{words}\", edit_#{s}_path(@#{s}) %> |\n"
|
|
365
|
+
buf << " <%= link_to \"Back to #{res.plural.tr("_", " ")}\", #{res.plural}_path %>\n"
|
|
366
|
+
buf << "</p>\n\n"
|
|
367
|
+
buf << "<%= button_to \"Destroy this #{words}\", #{s}_path(@#{s}), method: :delete %>\n"
|
|
368
|
+
buf
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def self.new_view(res)
|
|
372
|
+
s = res.singular
|
|
373
|
+
buf = +""
|
|
374
|
+
buf << "<h1>New #{s.tr("_", " ")}</h1>\n\n"
|
|
375
|
+
buf << "<%= render \"form\", #{s}: @#{s} %>\n\n"
|
|
376
|
+
buf << "<p><%= link_to \"Back to #{res.plural.tr("_", " ")}\", #{res.plural}_path %></p>\n"
|
|
377
|
+
buf
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def self.edit_view(res)
|
|
381
|
+
s = res.singular
|
|
382
|
+
buf = +""
|
|
383
|
+
buf << "<h1>Editing #{s.tr("_", " ")}</h1>\n\n"
|
|
384
|
+
buf << "<%= render \"form\", #{s}: @#{s} %>\n\n"
|
|
385
|
+
buf << "<p>\n"
|
|
386
|
+
buf << " <%= link_to \"Show this #{s.tr("_", " ")}\", #{s}_path(@#{s}) %> |\n"
|
|
387
|
+
buf << " <%= link_to \"Back to #{res.plural.tr("_", " ")}\", #{res.plural}_path %>\n"
|
|
388
|
+
buf << "</p>\n"
|
|
389
|
+
buf
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def self.form_partial(res)
|
|
393
|
+
s = res.singular
|
|
394
|
+
buf = +""
|
|
395
|
+
buf << "<%# locals: (#{s}:) %>\n"
|
|
396
|
+
buf << "<%= form_with(model: #{s}) do |f| %>\n"
|
|
397
|
+
buf << " <% if #{s}.errors.any? %>\n"
|
|
398
|
+
buf << " <div id=\"error_explanation\">\n"
|
|
399
|
+
buf << " <h2><%= pluralize(#{s}.errors.count, \"error\") %> prohibited this #{s.tr("_", " ")} from being saved:</h2>\n"
|
|
400
|
+
buf << " <ul>\n"
|
|
401
|
+
buf << " <% #{s}.errors.full_messages.each do |message| %>\n"
|
|
402
|
+
buf << " <li><%= message %></li>\n"
|
|
403
|
+
buf << " <% end %>\n"
|
|
404
|
+
buf << " </ul>\n"
|
|
405
|
+
buf << " </div>\n"
|
|
406
|
+
buf << " <% end %>\n\n"
|
|
407
|
+
res.fields.each do |f|
|
|
408
|
+
buf << " <div class=\"field\">\n"
|
|
409
|
+
buf << " <%= f.label :#{f.column_name} %>\n"
|
|
410
|
+
buf << " <%= f.#{f.form_input} :#{f.column_name} %>\n"
|
|
411
|
+
buf << " </div>\n\n"
|
|
412
|
+
end
|
|
413
|
+
buf << " <div class=\"actions\">\n"
|
|
414
|
+
buf << " <%= f.submit %>\n"
|
|
415
|
+
buf << " </div>\n"
|
|
416
|
+
buf << "<% end %>\n"
|
|
417
|
+
buf
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
end
|