cybertrain 0.2.0 → 0.2.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 +4 -4
- data/README.md +72 -10
- data/cybertrain/app_name.rb +36 -0
- data/cybertrain/cli/build.rb +71 -12
- data/cybertrain/cli/new_app.rb +5 -0
- data/cybertrain/cli/scaffold.rb +71 -11
- data/cybertrain/cli/templates.rb +9 -13
- data/cybertrain/cli.rb +9 -1
- data/cybertrain/generator/inflector.rb +3 -2
- data/cybertrain/ident.rb +171 -0
- data/cybertrain/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '0800072e567ad58afad9d1e6531aeff2aa0e82d8d9c69f2091fb6d3f5e0a5d61'
|
|
4
|
+
data.tar.gz: 9e93d0e6dc888ad648ab6acef7d78f52822b72f11aa7435dfd4aeae601334e8a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6df2e10e9e78787c2328aa2d96116d44bf437a3de08bd312b513129f8321043503df2fae5afc1b8b14e332e0c44d7990765d846e7bdfd04a8f503490f0066a3a
|
|
7
|
+
data.tar.gz: eb2b9396571a00406590c504e96c330937570609f0d8d860885f9cf739ab13e6c90a4859d5266d77f847fc2a7ac25078779721046237da3400e0ecde8a24e6e6
|
data/README.md
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
</p>
|
|
7
7
|
<p align="center">
|
|
8
8
|
<a href="https://saeki-mototsune.github.io/CyberTrain/">Homepage</a> ·
|
|
9
|
-
<a href="https://saeki-mototsune.github.io/CyberTrain/tutorial.html">Tutorial</a>
|
|
9
|
+
<a href="https://saeki-mototsune.github.io/CyberTrain/tutorial.html">Tutorial</a> ·
|
|
10
|
+
<a href="https://saeki-mototsune.github.io/CyberTrain/api/">API reference</a>
|
|
10
11
|
</p>
|
|
11
12
|
|
|
12
13
|
# cybertrain
|
|
@@ -34,6 +35,38 @@ authentication, mailers, jobs, WebSockets/ActionCable, an asset pipeline,
|
|
|
34
35
|
i18n, and anything beyond a minimal `render json:` — see "Differences from
|
|
35
36
|
Rails" below.
|
|
36
37
|
|
|
38
|
+
## Try it in the browser
|
|
39
|
+
|
|
40
|
+
[Open the playground in GitHub Codespaces](https://codespaces.new/saeki-mototsune/CyberTrain?quickstart=1)
|
|
41
|
+
to try cybertrain without installing anything: VS Code opens in your browser with
|
|
42
|
+
Spinel 2026.09.12, the `cybertrain` CLI and the blog from the walkthrough below
|
|
43
|
+
already created (`cybertrain new blog`, the article scaffold, the root route and
|
|
44
|
+
the first migration, built once so the server starts without compiling), its
|
|
45
|
+
development server running in a terminal and the app, showing the article list,
|
|
46
|
+
in the editor's preview. The app is a git repository with one commit, so Source
|
|
47
|
+
Control shows what you change. `PLAYGROUND.md` in the app lists what to try, from
|
|
48
|
+
editing a view to carrying on with the walkthrough's comments. A view edit shows
|
|
49
|
+
on the next reload; a Ruby edit is a full rebuild, about a minute, after which the
|
|
50
|
+
server restarts by itself; the preview does not reload by itself. `cybertrain new`
|
|
51
|
+
works there with no network.
|
|
52
|
+
|
|
53
|
+
- A GitHub account is required, and the codespace runs on your own Codespaces
|
|
54
|
+
quota: GitHub's free plan includes 120 core-hours and 15 GB-month of storage a
|
|
55
|
+
month, about 60 hours on the default 2-core machine the playground uses. GitHub
|
|
56
|
+
stops an idle codespace after 30 minutes by default; its storage counts until
|
|
57
|
+
you delete it.
|
|
58
|
+
- Inside the preview, pop-ups and `confirm()` dialogs do not work; the Ports
|
|
59
|
+
view's "Open in Browser" shows the app in a normal tab.
|
|
60
|
+
- The same image runs locally (it is published for linux/amd64; on arm64, build
|
|
61
|
+
it from a checkout):
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
docker run --rm -it --init -p 127.0.0.1:3000:3000 -e CYBERTRAIN_HOST=0.0.0.0 ghcr.io/saeki-mototsune/cybertrain-playground
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Then open http://localhost:3000. [playground/README.md](playground/README.md)
|
|
68
|
+
describes the image, building it and the Codespaces setup.
|
|
69
|
+
|
|
37
70
|
## Requirements
|
|
38
71
|
|
|
39
72
|
- Spinel `2026.09.12` (`Cybertrain::SPINEL_TAG`, the same release as
|
|
@@ -150,7 +183,7 @@ if they are not that release.
|
|
|
150
183
|
|
|
151
184
|
`cybertrain new` writes the application with its `spin.toml` pointing at
|
|
152
185
|
the release matching the CLI (`cybertrain = { git =
|
|
153
|
-
"https://github.com/saeki-mototsune/cybertrain", ref = "v0.2.
|
|
186
|
+
"https://github.com/saeki-mototsune/cybertrain", ref = "v0.2.1" }`), makes
|
|
154
187
|
sure Spinel is installed, then runs `spin lock` (spin fetches the framework
|
|
155
188
|
into its cache, `~/.cache/spin/packages/`, and pins the commit in
|
|
156
189
|
`spin.lock`) and `spin run gen`, much as `rails new` runs `bundle install`,
|
|
@@ -481,7 +514,10 @@ compiler output on every HTML response. None of this loads in production.
|
|
|
481
514
|
| Session holds any marshalled object | Session values are Strings only, HMAC-signed cookie |
|
|
482
515
|
| Many database adapters | SQLite only, via FFI |
|
|
483
516
|
| `has_many :through`, `includes`, `pluck`, `dependent:`, enums, STI, polymorphic associations | Not implemented; associations come from schema foreign keys only |
|
|
517
|
+
| Any column name; an association can shadow `errors` | A column name must be an ASCII identifier (`[A-Za-z_][A-Za-z0-9_]*`): a schema with a non-ASCII column name (`名前`, `prénom`) generated under CRuby before PR #10 and `spin run gen` refuses it now, so rename the column when upgrading (a name like `first-name` was never a method name). A column named like a method the generated class or the framework calls on a record (`errors`, `save`, `attributes`, `hash`, `to_s`, `to_ary`, `raise`, ...) or like a Ruby keyword (`end`, `class`, `begin`) generates under `<column>_column` (`hash_column`, reader, writer and ivar) with a note in the generated file; its SQL name keeps working in `read_attribute`, params and `attributes["hash"]`, and a template reaches it under either name (`post.hash`, `post.hash_column`), except `errors` and `to_param`, which a template resolves as the model methods, so use `post.errors_column` there; the query API (`where`, `order`, `find_by`) always takes the SQL column name (`Post.where(hash: 1)`), the Ruby name being the reader/writer and the template name only. A column named like another Object method (`display`, `tap`, `methods`) generates under its own name with a note. `cybertrain generate scaffold` refuses keywords and reserved names (it would have to call a renamed reader) and accepts a shadowing name with a note, like the generator. An association whose plain name a column, another association or a `Model` method already owns is emitted under a fallback name with a comment in the generated file: `<table>_as_<column stem>` for a `has_many` (`comments_as_article`), `<stem>_as_<column>` for a `belongs_to` (`author_as_author_id`) |
|
|
484
518
|
| `namespace`, format/`respond_to`, `constraints`, `mount` | Not implemented — flat names, `render json:` only |
|
|
519
|
+
| `rescue StandardError` catches a bad `JSON.parse` | Under Spinel `JSON::ParserError` is not a `StandardError`: app code must `rescue JSON::ParserError, StandardError`. The server catches it as a last resort and answers 500 |
|
|
520
|
+
| `order("lower(title)")`, `order("posts.title")`, `order(params[:sort])` | `order` takes only `column [ASC\|DESC]` lists (each column quoted) and raises `ArgumentError` (a 500: the string is the developer's) on anything else; request data must go through an allowlist first, e.g. `SORTS = { "title" => "title", "newest" => "created_at DESC" }` and `Post.order(SORTS.fetch(params[:sort].to_s, "id"))`, because a well-formed term can still name a missing column (a 500 from SQLite) or a column the client must not sort by; raw ORDER BY text goes through `order_sql` (`Post.order_sql("lower(title)")`, also on a relation), which must never see request data. A `limit`/`offset` on `delete_all` is honoured (a subselect), and request parameters nest at most 32 levels / 4096 pairs (400 past that). `Query.parse`, `Query.value_of`, `Query.decode` and `request.form_value` / `query_value` raise `Cybertrain::QueryMalformed` (a `StandardError` under `Cybertrain::QueryInvalid`, not an `ArgumentError`) on a malformed percent-escape, on both runtimes (`Cookies.parse` still keeps the raw value). They raise it too on an invalid UTF-8 byte sequence, raw (whatever the String's tag) or percent-encoded (Rails likewise answers 400 for an invalid parameter encoding), and so does a raw byte that only an escape would complete (`a=\xC3%A9`); `Cookies.parse` keeps such a value raw, and a path with an invalid sequence (raw, or percent-decoded in a segment) is a 400 as well (`request.path_segments`; a malformed escape in a path stays literal, a 404 by non-match). App code that rescued `ArgumentError` around them should rescue `Cybertrain::QueryInvalid` instead (upgrade note). The request keeps no parameter tree of its own (the Router builds `params`, the middleware read one key); an app that wants the whole tree parses it itself, `Query.parse(request.utf8_query_string)`, and owns the result |
|
|
485
521
|
| Full backtrace on an exception | Class, message, request line, template name/line — Spinel exposes no backtraces |
|
|
486
522
|
| Rack, its middleware, and any gem in a `Gemfile` | No Rack compatibility; a small fixed middleware set; Spinel's own `spin-index`, limited to what compiles under its Ruby subset |
|
|
487
523
|
| minitest / RSpec | `Cybertrain::Test` — reflection-based runners can't work ahead-of-time |
|
|
@@ -508,13 +544,27 @@ end
|
|
|
508
544
|
| `CYBERTRAIN_DATABASE` | `database_path` | `storage/#{env}.sqlite3` |
|
|
509
545
|
| `CYBERTRAIN_SECRET_KEY_BASE` | `secret_key_base` | required in production; dev/test auto-generate one into `tmp/secret_key` |
|
|
510
546
|
| `SPINEL_WORKERS` | `workers` | `1` |
|
|
547
|
+
| `CYBERTRAIN_HOST` | `host` | `"127.0.0.1"`; `0.0.0.0` listens on every interface (containers) |
|
|
548
|
+
| `CYBERTRAIN_SESSION_SAME_SITE` | `session_same_site` | `"Lax"`; also `Strict` or `None` (`None` always adds `Secure`); anything else stops the server at boot |
|
|
549
|
+
| `CYBERTRAIN_SESSION_PARTITIONED` | `session_partitioned` | `false`; `1` or `true` adds `Partitioned` (and `Secure`) |
|
|
550
|
+
|
|
551
|
+
Use `None` (with `CYBERTRAIN_SESSION_PARTITIONED=1`) only for an app shown
|
|
552
|
+
inside another site's frame, such as the playground's Codespaces preview; the
|
|
553
|
+
browser must reach the app over HTTPS.
|
|
511
554
|
|
|
512
|
-
Other attributes with fixed, overridable defaults: `
|
|
513
|
-
|
|
555
|
+
Other attributes with fixed, overridable defaults: `views_root`
|
|
556
|
+
(`"app/views"`), `public_root` (`"public"`), `layout`
|
|
514
557
|
(`"layouts/application"`), `log_level` (`:info`), `session_cookie_name`,
|
|
515
558
|
`session_max_age` (2 weeks), `session_secure` (`true` in production, which
|
|
516
|
-
marks the session cookie `Secure`; `false` elsewhere
|
|
517
|
-
`
|
|
559
|
+
marks the session cookie `Secure`; `false` elsewhere, though `SameSite=None`
|
|
560
|
+
and `Partitioned` add `Secure` anyway), `pool_size` (4), `static_files`/`csrf`
|
|
561
|
+
(`true`), `max_render_depth` (12 renders open at once: the page and its
|
|
562
|
+
partials; raise it for partials that legitimately recurse deeper; it must be
|
|
563
|
+
at least 1, or boot fails). Upgrade note: render nesting, unlimited before, is
|
|
564
|
+
now capped at 12 by default (the page is depth 1, so partials can nest 11
|
|
565
|
+
levels; the layout renders after the page and does not nest), and an app with
|
|
566
|
+
a deeper tree (threaded comments, a category menu) gets a template error until
|
|
567
|
+
it sets `max_render_depth`.
|
|
518
568
|
|
|
519
569
|
**Deployment:** `cybertrain build` produces `dist/`: the binary `dist/NAME`
|
|
520
570
|
with the views embedded, `dist/public/` (static assets, or let a reverse
|
|
@@ -590,11 +640,19 @@ prints the diff stat).
|
|
|
590
640
|
|
|
591
641
|
## Learn more
|
|
592
642
|
|
|
593
|
-
- [Homepage](https://saeki-mototsune.github.io/CyberTrain/)
|
|
594
|
-
[tutorial](https://saeki-mototsune.github.io/CyberTrain/tutorial.html)
|
|
643
|
+
- [Homepage](https://saeki-mototsune.github.io/CyberTrain/),
|
|
644
|
+
[tutorial](https://saeki-mototsune.github.io/CyberTrain/tutorial.html) and
|
|
645
|
+
[playground](https://saeki-mototsune.github.io/CyberTrain/playground.html) —
|
|
595
646
|
the source lives in [site/](site/), published by
|
|
596
647
|
[.github/workflows/pages.yml](.github/workflows/pages.yml); the logo files and
|
|
597
648
|
brand notes are in [site/assets/brand/](site/assets/brand/BRAND.md).
|
|
649
|
+
- [API reference](https://saeki-mototsune.github.io/CyberTrain/api/) — every
|
|
650
|
+
class and method an application uses (controllers, params, session and
|
|
651
|
+
flash, models and relations, routes, migrations, configuration, tests),
|
|
652
|
+
including what `spin run gen` generates. It is built by YARD
|
|
653
|
+
(`script/api-docs`, into `site/api/`) from the doc comments marked
|
|
654
|
+
`@api public` in `cybertrain/` and from [docs/api/](docs/api/); a method
|
|
655
|
+
without that tag is internal and stays out. CI fails on any YARD warning.
|
|
598
656
|
- [docs/design.md](docs/design.md) — the design record (Japanese): every
|
|
599
657
|
decision, what was rejected and why, and the Spinel constraints behind it.
|
|
600
658
|
- [docs/template-language.md](docs/template-language.md) — the full template
|
|
@@ -615,8 +673,12 @@ before the gem is pushed:
|
|
|
615
673
|
1. Bump `Cybertrain::VERSION` (`cybertrain/version.rb`) and `version` in
|
|
616
674
|
`spin.toml` together (CI checks they match); spin caches a git
|
|
617
675
|
dependency by that version.
|
|
618
|
-
2. Merge to `main`, then tag and push: `git tag v0.2.
|
|
619
|
-
3. `gem build cybertrain.gemspec && gem push cybertrain-0.2.
|
|
676
|
+
2. Merge to `main`, then tag and push: `git tag v0.2.1 && git push origin v0.2.1`.
|
|
677
|
+
3. `gem build cybertrain.gemspec && gem push cybertrain-0.2.1.gem`.
|
|
678
|
+
4. The tag also publishes the playground image
|
|
679
|
+
`ghcr.io/saeki-mototsune/cybertrain-playground:X.Y.Z` (and `latest`) through
|
|
680
|
+
[.github/workflows/playground-image.yml](.github/workflows/playground-image.yml);
|
|
681
|
+
check that run.
|
|
620
682
|
|
|
621
683
|
`Cybertrain::SPINEL_TAG` (also in `cybertrain/version.rb`) is the Spinel
|
|
622
684
|
release `cybertrain setup` installs. It must equal `SPINEL_TAG` in
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Cybertrain
|
|
2
|
+
# The one definition of "a name usable as the application's package name":
|
|
3
|
+
# `cybertrain build`/`server` (CLI::Build.app_name, reading spin.toml) and
|
|
4
|
+
# the development rebuilder (Dev::Rebuilder.new, which shells out with the
|
|
5
|
+
# name Application.new(name:) was given) both ask here, so the rules
|
|
6
|
+
# cannot drift apart. Application.new itself does not: production never
|
|
7
|
+
# builds. A leaf file with no requires: the CLI (CRuby) and the framework
|
|
8
|
+
# (Spinel) both load it. String methods only, no Regexp, because it
|
|
9
|
+
# compiles under Spinel (see Dev::Rebuilder).
|
|
10
|
+
module AppName
|
|
11
|
+
# "" when name works as a `spin build` target and as the build/bin/<name>
|
|
12
|
+
# path, else the reason (a String on every path, NOTES rules 10/34).
|
|
13
|
+
# Shell safety is not this method's job: both callers quote the name when
|
|
14
|
+
# they build a command ("my-app" and "MyApp" are fine for `spin build`),
|
|
15
|
+
# so only what breaks the target or the path is refused.
|
|
16
|
+
def self.problem(name)
|
|
17
|
+
# An empty name would become an explicit "" argument to spin.
|
|
18
|
+
return "cannot be empty" if name.empty?
|
|
19
|
+
# "." and ".." name build/bin/. and build/bin/.., which exist already
|
|
20
|
+
# (the latter is build/) and only fail later, in assemble's cp.
|
|
21
|
+
return "cannot be '.' or '..'" if name == "." || name == ".."
|
|
22
|
+
# Quoting leaves a leading "-" bare ("-" is in quote_arg's safe set),
|
|
23
|
+
# and even quoted `spin build '-x'` is parsed by spin as an option, not
|
|
24
|
+
# as the package name. Whether spin accepts `--` before the target is
|
|
25
|
+
# unverified, hence a refusal.
|
|
26
|
+
return "cannot start with '-' (spin would read it as an option)" if name.start_with?("-")
|
|
27
|
+
# A "/" would put the binary in a subdirectory of build/bin.
|
|
28
|
+
return "cannot contain '/'" if name.include?("/")
|
|
29
|
+
# Whitespace splits the target in the places that do not quote it, and
|
|
30
|
+
# a newline ends a command line.
|
|
31
|
+
return "cannot contain whitespace" if name.include?(" ") || name.include?("\t") || name.include?("\n") || name.include?("\r")
|
|
32
|
+
|
|
33
|
+
""
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/cybertrain/cli/build.rb
CHANGED
|
@@ -1,10 +1,42 @@
|
|
|
1
1
|
# `cybertrain build`: dist/ = the app binary with app/views embedded, plus
|
|
2
2
|
# public/. Also the command lists `cybertrain db` and `server` run. Plain Ruby:
|
|
3
3
|
# runs under CRuby (the gem) and compiles under Spinel (spin install).
|
|
4
|
+
require "cybertrain/app_name"
|
|
5
|
+
|
|
4
6
|
module Cybertrain
|
|
5
7
|
module CLI
|
|
6
8
|
module Build
|
|
7
|
-
# The
|
|
9
|
+
# The entries assemble() keeps in dist/ besides the binary: the copied
|
|
10
|
+
# public/ and the storage/ and tmp/ directories. A package named like
|
|
11
|
+
# one of them would collide with it (the binary renamed to dist/public
|
|
12
|
+
# gets moved aside as dist/.public.old and deleted; a dist/storage
|
|
13
|
+
# directory cannot be replaced by the binary), so `cybertrain build`
|
|
14
|
+
# (require_dist_name!) and `cybertrain new` refuse these names;
|
|
15
|
+
# app_name, which every command reads, does not: `cybertrain db` and
|
|
16
|
+
# `server` work for an existing app named like one. assemble builds its
|
|
17
|
+
# paths from these constants so the two cannot drift.
|
|
18
|
+
DIST_PUBLIC = "public"
|
|
19
|
+
DIST_STORAGE = "storage"
|
|
20
|
+
DIST_TMP = "tmp"
|
|
21
|
+
DIST_ENTRIES = [DIST_PUBLIC, DIST_STORAGE, DIST_TMP]
|
|
22
|
+
|
|
23
|
+
# "" when name cannot collide with what assemble() puts in dist/, else
|
|
24
|
+
# the reason. A leading "." covers assemble's scratch entries
|
|
25
|
+
# (dist/.<name>.tmp, dist/.public.tmp, dist/.public.old) and "."/"..".
|
|
26
|
+
# The entry names match case-insensitively: on a case-insensitive
|
|
27
|
+
# filesystem (macOS's default APFS, one of the CI OSes; Windows) "Public"
|
|
28
|
+
# and "TMP" are the same directory entry as public and tmp. Only a
|
|
29
|
+
# different spelling of the whole name ("tmp2", "my_public") is another
|
|
30
|
+
# entry. downcase is ASCII-safe here: the entries are ASCII.
|
|
31
|
+
def self.dist_name_problem(name)
|
|
32
|
+
return "" unless DIST_ENTRIES.include?(name.downcase) || name.start_with?(".")
|
|
33
|
+
|
|
34
|
+
"collides with what `cybertrain build` keeps in dist/ (#{DIST_ENTRIES.join(", ")}) or its scratch entries: it cannot be one of those names (in any letter case) or start with '.'"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The [package] name in root/spin.toml, or "" when there is none. The
|
|
38
|
+
# name ends up in shell command strings and file paths, so it must be
|
|
39
|
+
# a name `cybertrain new` could have produced.
|
|
8
40
|
def self.app_name(root)
|
|
9
41
|
path = "#{root}/spin.toml"
|
|
10
42
|
return "" unless File.exist?(path)
|
|
@@ -22,11 +54,35 @@ module Cybertrain
|
|
|
22
54
|
break
|
|
23
55
|
end
|
|
24
56
|
end
|
|
57
|
+
# An empty name is run_in_app's "no spin.toml with a [package] name".
|
|
58
|
+
# Any other name must be usable as a `spin build` target and a
|
|
59
|
+
# build/bin/<name> path: the same predicate Application.new applies to
|
|
60
|
+
# the dev rebuilder's target. The name reaches the shell through
|
|
61
|
+
# quote_arg, so only what breaks that target or path is refused.
|
|
62
|
+
return "" if name.empty?
|
|
63
|
+
|
|
64
|
+
problem = AppName.problem(name)
|
|
65
|
+
raise InvalidArgument, "spin.toml [package] name '#{name}' #{problem}" unless problem.empty?
|
|
66
|
+
|
|
25
67
|
name
|
|
26
68
|
end
|
|
27
69
|
|
|
70
|
+
# The build path's check, made once in CLI.build_app before the
|
|
71
|
+
# toolchain is touched (Build.run does not repeat it): a name that
|
|
72
|
+
# collides with a dist/ entry only matters to assemble. Returns nil.
|
|
73
|
+
def self.require_dist_name!(name)
|
|
74
|
+
problem = dist_name_problem(name)
|
|
75
|
+
raise InvalidArgument, "spin.toml [package] name '#{name}' #{problem}" unless problem.empty?
|
|
76
|
+
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# name goes through quote_arg too: app_name validates it, but the
|
|
81
|
+
# command strings stay safe for a caller that skipped that, whatever the
|
|
82
|
+
# spelling. An empty name cannot reach these from the CLI (run_in_app
|
|
83
|
+
# refuses it first); a direct caller passing "" gets `spin build ''`.
|
|
28
84
|
def self.commands(name)
|
|
29
|
-
["spin run gen -- --embed-views", "spin build #{name}", "spin run gen"]
|
|
85
|
+
["spin run gen -- --embed-views", "spin build #{quote_arg(name)}", "spin run gen"]
|
|
30
86
|
end
|
|
31
87
|
|
|
32
88
|
# `cybertrain db ARGS`, through bin/db.rb (the app binary cannot compile
|
|
@@ -55,7 +111,8 @@ module Cybertrain
|
|
|
55
111
|
|
|
56
112
|
# port is "" (the app's default, 3000) or a port? string.
|
|
57
113
|
def self.server_commands(name, port)
|
|
58
|
-
|
|
114
|
+
target = quote_arg(name)
|
|
115
|
+
run = port == "" ? "spin run #{target}" : "spin run #{target} -- #{quote_arg(port)}"
|
|
59
116
|
["spin run gen", run]
|
|
60
117
|
end
|
|
61
118
|
|
|
@@ -84,6 +141,8 @@ module Cybertrain
|
|
|
84
141
|
end
|
|
85
142
|
|
|
86
143
|
# Runs the build in root and assembles dist/. Returns the exit code.
|
|
144
|
+
# name must already have passed require_dist_name!: CLI.build_app checks
|
|
145
|
+
# it once, before the toolchain is fetched.
|
|
87
146
|
def self.run(root, name, runner = Runner.new)
|
|
88
147
|
steps = commands(name)
|
|
89
148
|
ok = false
|
|
@@ -138,8 +197,8 @@ module Cybertrain
|
|
|
138
197
|
dist = "#{root}/dist"
|
|
139
198
|
Templates.mkdir_p(dist)
|
|
140
199
|
binary_tmp = "#{dist}/.#{name}.tmp"
|
|
141
|
-
public_tmp = "#{dist}
|
|
142
|
-
public_old = "#{dist}
|
|
200
|
+
public_tmp = "#{dist}/.#{DIST_PUBLIC}.tmp"
|
|
201
|
+
public_old = "#{dist}/.#{DIST_PUBLIC}.old"
|
|
143
202
|
# Leftovers of an interrupted earlier run.
|
|
144
203
|
rm_tree(binary_tmp)
|
|
145
204
|
rm_tree(public_tmp)
|
|
@@ -150,20 +209,20 @@ module Cybertrain
|
|
|
150
209
|
|
|
151
210
|
File.rename(binary_tmp, "#{dist}/#{name}")
|
|
152
211
|
|
|
153
|
-
if File.directory?("#{root}
|
|
154
|
-
public_command = "cp -R #{shell_quote("#{root}
|
|
155
|
-
raise InvalidArgument, "could not copy
|
|
212
|
+
if File.directory?("#{root}/#{DIST_PUBLIC}")
|
|
213
|
+
public_command = "cp -R #{shell_quote("#{root}/#{DIST_PUBLIC}")} #{shell_quote(public_tmp)}"
|
|
214
|
+
raise InvalidArgument, "could not copy #{DIST_PUBLIC}/ to dist/" unless system(public_command)
|
|
156
215
|
else
|
|
157
216
|
Dir.mkdir(public_tmp)
|
|
158
217
|
end
|
|
159
|
-
public_dir = "#{dist}
|
|
218
|
+
public_dir = "#{dist}/#{DIST_PUBLIC}"
|
|
160
219
|
File.rename(public_dir, public_old) if File.exist?(public_dir) || File.symlink?(public_dir)
|
|
161
220
|
File.rename(public_tmp, public_dir)
|
|
162
221
|
rm_tree(public_old)
|
|
163
222
|
|
|
164
|
-
Templates.mkdir_p("#{dist}
|
|
165
|
-
Templates.mkdir_p("#{dist}
|
|
166
|
-
["dist/#{name}", "dist/
|
|
223
|
+
Templates.mkdir_p("#{dist}/#{DIST_STORAGE}")
|
|
224
|
+
Templates.mkdir_p("#{dist}/#{DIST_TMP}")
|
|
225
|
+
["dist/#{name}", "dist/#{DIST_PUBLIC}/", "dist/#{DIST_STORAGE}/", "dist/#{DIST_TMP}/"]
|
|
167
226
|
end
|
|
168
227
|
|
|
169
228
|
# A symlink is removed itself, never followed: its target may be
|
data/cybertrain/cli/new_app.rb
CHANGED
|
@@ -14,6 +14,11 @@ module Cybertrain
|
|
|
14
14
|
# relative to dir.
|
|
15
15
|
def self.create(dir, framework_dep)
|
|
16
16
|
package = File.basename(dir)
|
|
17
|
+
# Before anything is written: `public`, `storage` and `tmp` are valid
|
|
18
|
+
# identifiers but collide with dist/ entries (Build.dist_name_problem).
|
|
19
|
+
problem = Build.dist_name_problem(package)
|
|
20
|
+
raise InvalidArgument, "'#{package}' is not a valid app name: it #{problem}" unless problem.empty?
|
|
21
|
+
|
|
17
22
|
title = Inflector.camelize(package)
|
|
18
23
|
files = [
|
|
19
24
|
["spin.toml", Templates.spin_toml(package, framework_dep)],
|
data/cybertrain/cli/scaffold.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# migration, model, controller and views of a resource and adds
|
|
3
3
|
# `resources :posts` to config/routes.rb, the way Rails' scaffold does.
|
|
4
4
|
require "cybertrain/generator/inflector"
|
|
5
|
+
require "cybertrain/ident"
|
|
5
6
|
require "cybertrain/cli/templates"
|
|
6
7
|
|
|
7
8
|
module Cybertrain
|
|
@@ -15,17 +16,12 @@ module Cybertrain
|
|
|
15
16
|
TYPES = %w[string text integer float boolean date datetime references]
|
|
16
17
|
|
|
17
18
|
# Columns every table already has (the primary key and t.timestamps).
|
|
19
|
+
# Everything else a column may not be called -- Ruby keywords, names
|
|
20
|
+
# that collide with the generated model -- comes from Cybertrain::Ident,
|
|
21
|
+
# the same rules the generator applies, so the scaffold refuses the
|
|
22
|
+
# name instead of writing files that `spin run gen` then rejects.
|
|
18
23
|
RESERVED_COLUMNS = %w[id created_at updated_at]
|
|
19
24
|
|
|
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
25
|
attr_reader :field_name, :field_type
|
|
30
26
|
|
|
31
27
|
def self.parse(arg)
|
|
@@ -38,11 +34,18 @@ module Cybertrain
|
|
|
38
34
|
type = parts.size > 1 ? parts[1].to_s : "string"
|
|
39
35
|
type = "references" if type == "belongs_to"
|
|
40
36
|
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
37
|
raise InvalidArgument, "unknown type '#{type}' for #{name} (use #{TYPES.join(", ")})" unless TYPES.include?(type)
|
|
43
38
|
|
|
44
39
|
field = Field.new(name, type)
|
|
45
40
|
raise InvalidArgument, "'#{field.column_name}' is a column every table already has" if RESERVED_COLUMNS.include?(field.column_name)
|
|
41
|
+
# Ident.unusable_reason is the one definition of a name the scaffold
|
|
42
|
+
# must not invent; Scaffold.refuse_unusable! turns its answer into the
|
|
43
|
+
# message. A name that only shadows an Object method is accepted, as the
|
|
44
|
+
# generator accepts it, and Scaffold.generate prints a note.
|
|
45
|
+
Scaffold.refuse_unusable!(field.column_name, "field")
|
|
46
|
+
# A references field also defines the reader `def <name>` (post:references
|
|
47
|
+
# -> def post), which answers to the same rules as a column.
|
|
48
|
+
Scaffold.refuse_unusable!(name, "reference") if field.reference?
|
|
46
49
|
|
|
47
50
|
field
|
|
48
51
|
end
|
|
@@ -109,6 +112,36 @@ module Cybertrain
|
|
|
109
112
|
module Scaffold
|
|
110
113
|
DRAW_LINE = "Cybertrain::Routes.draw do"
|
|
111
114
|
|
|
115
|
+
# Policy: the scaffold refuses what the generator would RENAME (Ruby
|
|
116
|
+
# keywords, Ident::RESERVED_COLUMN_NAMES): its views and controller call
|
|
117
|
+
# the reader by the field's name, so a renamed reader would break them.
|
|
118
|
+
# It accepts what the generator only annotates (a name that shadows an
|
|
119
|
+
# Object method, `method`, `display`, `send`) and says so on stdout
|
|
120
|
+
# (shadow_notes), as the generator does in the model file.
|
|
121
|
+
#
|
|
122
|
+
# Raises InvalidArgument when `name` is a name the scaffold must not
|
|
123
|
+
# invent, with the message for the reason Ident.unusable_reason gives;
|
|
124
|
+
# `what` says what the name would name ("field", "reference",
|
|
125
|
+
# "resource"). The one place that maps a reason to a message, shared by the
|
|
126
|
+
# field column, the references reader and the resource singular: a
|
|
127
|
+
# reason Ident adds must be added here, and the `else` raises for one
|
|
128
|
+
# that is not (a `case` without it would return nil and let the name
|
|
129
|
+
# through silently). Returns nil, explicitly, for a usable name (NOTES
|
|
130
|
+
# rules 10/34: one return type).
|
|
131
|
+
def self.refuse_unusable!(name, what)
|
|
132
|
+
case Ident.unusable_reason(name)
|
|
133
|
+
when ""
|
|
134
|
+
nil
|
|
135
|
+
when "keyword"
|
|
136
|
+
raise InvalidArgument, "'#{name}' is a Ruby keyword and cannot name a #{what}"
|
|
137
|
+
when "reserved"
|
|
138
|
+
raise InvalidArgument, "'#{name}' would shadow a method of the generated model (Cybertrain::Model); pick another #{what}"
|
|
139
|
+
else
|
|
140
|
+
raise RuntimeError, "Ident.unusable_reason('#{name}') answered a reason Scaffold.refuse_unusable! has no message for"
|
|
141
|
+
end
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
112
145
|
# Returns the paths it created (files that already exist are reported
|
|
113
146
|
# as "identical" or "exist" and left alone). Raises InvalidArgument on a
|
|
114
147
|
# bad name, a bad or duplicate field, or a routes file without a
|
|
@@ -120,7 +153,18 @@ module Cybertrain
|
|
|
120
153
|
|
|
121
154
|
underscored = Inflector.underscore(name)
|
|
122
155
|
raise InvalidArgument, "bad resource name '#{name}'" unless Templates.identifier?(underscored)
|
|
123
|
-
|
|
156
|
+
singular = Inflector.singularize(underscored)
|
|
157
|
+
# Policy (refuse_unusable!): the scaffold refuses a singular the
|
|
158
|
+
# generator would RENAME. A reserved one (update, save) would give
|
|
159
|
+
# every referencing model a `<stem>_as_<col>` reader, and the singular
|
|
160
|
+
# is also the model class name. A shadowing one (display, tap) is
|
|
161
|
+
# accepted with a note (shadow_notes). The generator itself still
|
|
162
|
+
# generates a schema with a reserved table (a hand-written table named
|
|
163
|
+
# `updates` works), so this is the scaffold's choice, not the
|
|
164
|
+
# generator's refusal. The plural (the has_many side) is left to the
|
|
165
|
+
# generator, which renames such a reader (`errors_as_post`) -- and
|
|
166
|
+
# nothing may reference the table.
|
|
167
|
+
Scaffold.refuse_unusable!(singular, "resource")
|
|
124
168
|
|
|
125
169
|
parsed = Array.new(0) { Field.new("", "") }
|
|
126
170
|
columns = Array.new(0) { "" }
|
|
@@ -145,9 +189,25 @@ module Cybertrain
|
|
|
145
189
|
record(created, root, "#{views}/edit.html.erb", Templates.edit_view(res))
|
|
146
190
|
record(created, root, "#{views}/_form.html.erb", Templates.form_partial(res))
|
|
147
191
|
add_route(routes_path, res.plural)
|
|
192
|
+
shadow_notes(res).each { |note| puts note }
|
|
148
193
|
created
|
|
149
194
|
end
|
|
150
195
|
|
|
196
|
+
# One line per name the scaffold accepted that shadows an Object method
|
|
197
|
+
# (Ident::SHADOWING_COLUMN_NAMES), the generator's own note told on
|
|
198
|
+
# stdout: a field (or references reader) on the model, the resource
|
|
199
|
+
# singular on every model that references it.
|
|
200
|
+
def self.shadow_notes(res)
|
|
201
|
+
notes = Array.new(0) { "" }
|
|
202
|
+
res.fields.each do |f|
|
|
203
|
+
notes << "note: '#{f.field_name}' shadows Object##{f.field_name} on the generated model; call it as #{res.singular}.#{f.field_name}" if Ident.shadowing_column?(f.field_name)
|
|
204
|
+
end
|
|
205
|
+
if Ident.shadowing_column?(res.singular)
|
|
206
|
+
notes << "note: '#{res.singular}' shadows Object##{res.singular} on a model that references #{res.plural} (belongs_to reader); call it as record.#{res.singular}"
|
|
207
|
+
end
|
|
208
|
+
notes
|
|
209
|
+
end
|
|
210
|
+
|
|
151
211
|
def self.record(created, root, path, content)
|
|
152
212
|
created << path if Templates.write(root, path, content) == "create"
|
|
153
213
|
nil
|
data/cybertrain/cli/templates.rb
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
# The scaffold templates take a Cybertrain::CLI::Resource (cli/scaffold.rb).
|
|
6
6
|
# Generated views stay inside the template language of docs/design.md
|
|
7
7
|
# section 7: literals, @ivars, locals, helper calls, `if` and `each do`.
|
|
8
|
+
require "cybertrain/ident"
|
|
9
|
+
|
|
8
10
|
module Cybertrain
|
|
9
11
|
module CLI
|
|
10
12
|
module Templates
|
|
@@ -39,19 +41,12 @@ module Cybertrain
|
|
|
39
41
|
nil
|
|
40
42
|
end
|
|
41
43
|
|
|
42
|
-
# A lowercase Ruby-ish name: "post", "blog_post", "released_on".
|
|
44
|
+
# A lowercase Ruby-ish name: "post", "blog_post", "released_on". The
|
|
45
|
+
# snake_case subset of Ident.column? (what the generator accepts),
|
|
46
|
+
# letter-first since it names packages, binaries and files too; one
|
|
47
|
+
# definition of the characters, so the two cannot drift apart.
|
|
43
48
|
def self.identifier?(word)
|
|
44
|
-
|
|
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
|
|
49
|
+
Ident.snake_case?(word, false)
|
|
55
50
|
end
|
|
56
51
|
|
|
57
52
|
# "blog_posts" -> "Blog posts"
|
|
@@ -115,7 +110,8 @@ module Cybertrain
|
|
|
115
110
|
<<~RUBY
|
|
116
111
|
# Application settings; see Cybertrain::Config for every option.
|
|
117
112
|
# Environment variables (PORT, CYBERTRAIN_ENV, CYBERTRAIN_DATABASE,
|
|
118
|
-
# CYBERTRAIN_SECRET_KEY_BASE
|
|
113
|
+
# CYBERTRAIN_SECRET_KEY_BASE, CYBERTRAIN_HOST, CYBERTRAIN_SESSION_SAME_SITE,
|
|
114
|
+
# CYBERTRAIN_SESSION_PARTITIONED) are read before this block runs.
|
|
119
115
|
Cybertrain.configure do |c|
|
|
120
116
|
# c.port = 3000
|
|
121
117
|
# c.workers = 1
|
data/cybertrain/cli.rb
CHANGED
|
@@ -67,7 +67,7 @@ module Cybertrain
|
|
|
67
67
|
when "migration" then run_in_app { |_name| run_spin(Build.db_commands(["migrate"])) }
|
|
68
68
|
when "db" then run_in_app { |_name| run_db(argv) }
|
|
69
69
|
when "server" then run_in_app { |name| run_server(name, argv) }
|
|
70
|
-
when "build" then run_in_app { |name|
|
|
70
|
+
when "build" then run_in_app { |name| build_app(name) }
|
|
71
71
|
when "spin" then run_spin_passthrough(argv)
|
|
72
72
|
when "setup" then Toolchain.setup(argv[1, argv.size - 1])
|
|
73
73
|
when "doctor" then run_doctor(argv)
|
|
@@ -163,6 +163,14 @@ module Cybertrain
|
|
|
163
163
|
yield name
|
|
164
164
|
end
|
|
165
165
|
|
|
166
|
+
# `cybertrain build`: the one dist/ name check, made before the toolchain
|
|
167
|
+
# is fetched or any spin command runs; Build.run assumes a name that got
|
|
168
|
+
# past it.
|
|
169
|
+
def self.build_app(name)
|
|
170
|
+
Build.require_dist_name!(name)
|
|
171
|
+
Toolchain.ensure! ? Build.run(".", name) : 1
|
|
172
|
+
end
|
|
173
|
+
|
|
166
174
|
# Runs spin commands in turn once a Spinel of the pinned release is on
|
|
167
175
|
# PATH; stops at the first failure.
|
|
168
176
|
def self.run_spin(commands)
|
|
@@ -55,8 +55,9 @@ module Cybertrain
|
|
|
55
55
|
def self.underscore(camel_cased_word)
|
|
56
56
|
return camel_cased_word.dup unless camel_cased_word.match?(/[A-Z-]|::/)
|
|
57
57
|
|
|
58
|
-
# Two steps: in a threaded program a
|
|
59
|
-
#
|
|
58
|
+
# Two steps, the gsub result held in a local: in a threaded program a
|
|
59
|
+
# collection inside `chars` frees an unnamed gsub result it is
|
|
60
|
+
# splitting ("Post" comes back as "").
|
|
60
61
|
path = camel_cased_word.gsub("::", "/")
|
|
61
62
|
chars = path.chars
|
|
62
63
|
out = +""
|
data/cybertrain/ident.rb
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Cybertrain::Ident -- the one place that says what a column (or scaffold
|
|
2
|
+
# field) may be called. Used by the generator (ModelsEmitter), the ORM
|
|
3
|
+
# (Relation#order) and the CLI (`cybertrain generate scaffold`), so a name
|
|
4
|
+
# the scaffold accepts is a name the generator and the models accept, and
|
|
5
|
+
# the failure comes from the command that would write the files.
|
|
6
|
+
#
|
|
7
|
+
# Plain Ruby with no requires: the CLI gem ships this file and runs it under
|
|
8
|
+
# CRuby, the framework compiles it under Spinel (no Regexp, NOTES rule 1).
|
|
9
|
+
module Cybertrain
|
|
10
|
+
module Ident
|
|
11
|
+
# Column names whose plain reader the generated class cannot host: it
|
|
12
|
+
# would shadow a method the generated class or the framework calls on the
|
|
13
|
+
# record. The generator renames them, so an existing schema keeps
|
|
14
|
+
# generating: the column reads as `<column>_column`
|
|
15
|
+
# (ModelsEmitter.reader_name). The scaffold refuses them: its views and
|
|
16
|
+
# controller call the reader by the field's name, which a renamed reader
|
|
17
|
+
# would break. `id` is deliberately
|
|
18
|
+
# absent: the primary key is Model#id. Names that end in `?` or `!`
|
|
19
|
+
# cannot be columns at all (column? rejects them), so `valid?`,
|
|
20
|
+
# `persisted?`, `is_a?` need no entry; keywords (`class`) live in
|
|
21
|
+
# RUBY_KEYWORDS only, and the generator renames those the same way
|
|
22
|
+
# (`class_column`). Class methods (`table_name`, `column_names`,
|
|
23
|
+
# `from_row`) need no entry either: the framework calls them on the
|
|
24
|
+
# class (`self.class.table_name`, `Post.from_row`), which a column
|
|
25
|
+
# reader on the instance does not shadow, so a schema with a
|
|
26
|
+
# `table_name` column keeps generating. script/check-reserved-names
|
|
27
|
+
# keeps the Model and Object groups here and in SHADOWING_COLUMN_NAMES
|
|
28
|
+
# in step with the code (it skips `def self.` methods).
|
|
29
|
+
RESERVED_COLUMN_NAMES = [
|
|
30
|
+
# Cybertrain::Model: its methods and the ivars behind them (`errors`,
|
|
31
|
+
# `persisted` -> @errors, @persisted)
|
|
32
|
+
"errors", "persisted", "attributes", "save", "update", "destroy",
|
|
33
|
+
"reload", "model_name", "to_json", "as_json", "to_param", "to_row",
|
|
34
|
+
"load_row", "set_id", "run_callbacks", "insert_row", "update_row",
|
|
35
|
+
"read_attribute", "write_attribute", "assign_attributes",
|
|
36
|
+
"read_association", "call_view_method", "initialize",
|
|
37
|
+
# Object methods Ruby or the framework calls on a record without being
|
|
38
|
+
# asked: string interpolation and templates (to_s), error messages
|
|
39
|
+
# (inspect), Hash keys (hash)
|
|
40
|
+
"hash", "inspect", "to_s",
|
|
41
|
+
# Implicit-conversion and dispatch hooks Ruby calls on its own (none is
|
|
42
|
+
# defined on Object, so no reflection finds them): an arity-0 reader
|
|
43
|
+
# returning a String breaks `puts post` / `[post].flatten` / a splat
|
|
44
|
+
# (to_ary), string coercion (to_str), keyword splats (to_hash), `&post`
|
|
45
|
+
# (to_proc), Integer coercion (to_int), and turns every NoMethodError
|
|
46
|
+
# into an ArgumentError (method_missing). `respond_to_missing?` ends in
|
|
47
|
+
# `?` and needs no entry.
|
|
48
|
+
"to_ary", "to_str", "to_hash", "to_proc", "to_int", "to_a", "to_h",
|
|
49
|
+
"to_sym", "to_io", "to_path", "to_regexp", "coerce", "method_missing",
|
|
50
|
+
# Hooks Ruby itself calls WITH an argument, which it finds whether they
|
|
51
|
+
# are public or private (so Object.instance_methods never lists the
|
|
52
|
+
# private ones): `record.dup` / `clone` call initialize_copy(orig)
|
|
53
|
+
# (initialize_dup / initialize_clone on the way), and defining, removing
|
|
54
|
+
# or undefining a singleton method (`def record.x`) calls
|
|
55
|
+
# singleton_method_added / _removed / _undefined. A zero-arity column
|
|
56
|
+
# reader in their place raises ArgumentError at that call. These are
|
|
57
|
+
# reserved, not merely shadowing: the problem is Ruby's call, not a
|
|
58
|
+
# method the caller could have wanted. script/check-reserved-names
|
|
59
|
+
# fails when one of Ruby's private hooks is missing here.
|
|
60
|
+
"initialize_copy", "initialize_dup", "initialize_clone",
|
|
61
|
+
"singleton_method_added", "singleton_method_removed",
|
|
62
|
+
"singleton_method_undefined",
|
|
63
|
+
# The one Kernel method the model calls on implicit self (Model#save!
|
|
64
|
+
# and #reload would dispatch `raise RecordInvalid, ...` to the
|
|
65
|
+
# zero-arity reader), and its alias. No other private Kernel method is
|
|
66
|
+
# reserved: `open`, `format`, `load`, `print`, `select`, `test` are
|
|
67
|
+
# ordinary column names and nothing in the generated class calls them.
|
|
68
|
+
"raise", "fail"
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
# The rest of Object's public instance methods: a column so named shadows
|
|
72
|
+
# them on its model (`record.display`, `record.tap` are the column), but
|
|
73
|
+
# neither the generated class nor the framework calls any of them on a
|
|
74
|
+
# record, so the generator accepts the column and writes a note into the
|
|
75
|
+
# generated file instead of refusing an existing schema. The scaffold
|
|
76
|
+
# accepts them the same way, with a note on stdout (Scaffold.shadow_notes):
|
|
77
|
+
# `payment.method` is the field, which its views and controller call.
|
|
78
|
+
SHADOWING_COLUMN_NAMES = [
|
|
79
|
+
"object_id", "__id__", "send", "__send__", "public_send", "freeze",
|
|
80
|
+
"display", "method", "methods", "public_method", "public_methods",
|
|
81
|
+
"private_methods", "protected_methods", "singleton_class",
|
|
82
|
+
"singleton_method", "singleton_methods", "define_singleton_method",
|
|
83
|
+
"remove_instance_variable", "instance_variable_get",
|
|
84
|
+
"instance_variable_set", "instance_variables", "instance_eval",
|
|
85
|
+
"instance_exec", "dup", "clone", "tap", "yield_self", "itself",
|
|
86
|
+
"extend", "enum_for", "to_enum"
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
# Ruby keywords a column could be spelled like: `@end` / `def class` are
|
|
90
|
+
# not shapes worth supporting. The generator reads such a column as
|
|
91
|
+
# `<column>_column` (ModelsEmitter.reader_name), like a reserved name;
|
|
92
|
+
# the scaffold refuses them (its views call the reader by name). The
|
|
93
|
+
# uppercase ones are reachable since column? accepts capitals. `defined`
|
|
94
|
+
# is not here: the keyword is `defined?`, which column? rejects, and
|
|
95
|
+
# `defined` is an ordinary method name (`attr_accessor :defined` works).
|
|
96
|
+
RUBY_KEYWORDS = [
|
|
97
|
+
"alias", "and", "begin", "break", "case", "class", "def", "do",
|
|
98
|
+
"else", "elsif", "end", "ensure", "false", "for", "if", "in",
|
|
99
|
+
"module", "next", "nil", "not", "or", "redo", "rescue", "retry",
|
|
100
|
+
"return", "self", "super", "then", "true", "undef", "unless", "until",
|
|
101
|
+
"when", "while", "yield",
|
|
102
|
+
"BEGIN", "END", "__ENCODING__", "__FILE__", "__LINE__"
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
# /\A[A-Za-z_][A-Za-z0-9_]*\z/ spelled out: what Ruby needs of a method
|
|
106
|
+
# name, so a schema with camelCase or mixed-case columns (`createdAt`,
|
|
107
|
+
# `userId`, a legacy SQLite table) generates under its own name, not a
|
|
108
|
+
# renamed one; `attr_accessor :createdAt` and `attr_accessor :Title` both
|
|
109
|
+
# compile and dispatch under Spinel (probed on 2026.09.12). The
|
|
110
|
+
# scaffold, which chooses new names, keeps the stricter snake_case rule
|
|
111
|
+
# (CLI::Templates.identifier?). A flag rather than a `return` inside
|
|
112
|
+
# the block, like Templates.identifier?: a `return` from an each_char
|
|
113
|
+
# block does leave the method under Spinel (probed), but the flag needs
|
|
114
|
+
# no such guarantee.
|
|
115
|
+
def self.column?(name)
|
|
116
|
+
return false if name.empty?
|
|
117
|
+
|
|
118
|
+
ok = true
|
|
119
|
+
first = true
|
|
120
|
+
name.each_char do |ch|
|
|
121
|
+
letter = (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z")
|
|
122
|
+
digit = ch >= "0" && ch <= "9"
|
|
123
|
+
ok = false unless letter || ch == "_" || (digit && !first)
|
|
124
|
+
first = false
|
|
125
|
+
end
|
|
126
|
+
ok
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The snake_case subset of column?: lowercase only, and a leading `_`
|
|
130
|
+
# only when allowed. CLI::Templates.identifier? (app, resource and field
|
|
131
|
+
# names: letter-first) and Template::Lexer.identifier? (template locals
|
|
132
|
+
# and block parameters: `_x` allowed) are both this predicate, so the two
|
|
133
|
+
# cannot drift. A positional default rather than a keyword argument: this
|
|
134
|
+
# file compiles under Spinel and keeps to the shapes it already uses.
|
|
135
|
+
def self.snake_case?(word, leading_underscore = true)
|
|
136
|
+
column?(word) && word == word.downcase && (leading_underscore || !word.start_with?("_"))
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.keyword?(name)
|
|
140
|
+
RUBY_KEYWORDS.include?(name)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.reserved_column?(name)
|
|
144
|
+
RESERVED_COLUMN_NAMES.include?(name)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def self.shadowing_column?(name)
|
|
148
|
+
SHADOWING_COLUMN_NAMES.include?(name)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The one definition of "a name the scaffold must not invent": "" when
|
|
152
|
+
# `name` may be chosen, otherwise why not -- "keyword" (RUBY_KEYWORDS) or
|
|
153
|
+
# "reserved" (RESERVED_COLUMN_NAMES): the two kinds the generator RENAMES,
|
|
154
|
+
# which the scaffold cannot accept because its views and controller call
|
|
155
|
+
# the reader by the field's name. A SHADOWING_COLUMN_NAMES entry is not
|
|
156
|
+
# refused: the generator keeps its name with a note, and so does the
|
|
157
|
+
# scaffold (shadowing_column?). The scaffold asks here at every place it
|
|
158
|
+
# takes a name from the user (field column, references reader, resource
|
|
159
|
+
# singular) and raises the message for the reason, so a new list of names
|
|
160
|
+
# is added to this method once, not to each call site. A String, not nil
|
|
161
|
+
# or a Symbol, so the return type is the same on every path (NOTES rules
|
|
162
|
+
# 10/34); a keyword that is also listed in a column array reports
|
|
163
|
+
# "keyword" first.
|
|
164
|
+
def self.unusable_reason(name)
|
|
165
|
+
return "keyword" if keyword?(name)
|
|
166
|
+
return "reserved" if reserved_column?(name)
|
|
167
|
+
|
|
168
|
+
""
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
data/cybertrain/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cybertrain
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Saeki Mototsune
|
|
@@ -22,6 +22,7 @@ extensions: []
|
|
|
22
22
|
extra_rdoc_files: []
|
|
23
23
|
files:
|
|
24
24
|
- README.md
|
|
25
|
+
- cybertrain/app_name.rb
|
|
25
26
|
- cybertrain/cli.rb
|
|
26
27
|
- cybertrain/cli/build.rb
|
|
27
28
|
- cybertrain/cli/new_app.rb
|
|
@@ -29,6 +30,7 @@ files:
|
|
|
29
30
|
- cybertrain/cli/templates.rb
|
|
30
31
|
- cybertrain/cli/toolchain.rb
|
|
31
32
|
- cybertrain/generator/inflector.rb
|
|
33
|
+
- cybertrain/ident.rb
|
|
32
34
|
- cybertrain/version.rb
|
|
33
35
|
- exe/cybertrain
|
|
34
36
|
homepage: https://github.com/saeki-mototsune/cybertrain
|