thecore_generators 3.2.0 → 3.8.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 +4 -4
- data/README.md +215 -2
- data/lib/generators/thecore/action_companion.rb +116 -0
- data/lib/generators/thecore/association_wiring.rb +217 -0
- data/lib/generators/thecore/atom_aware.rb +14 -3
- data/lib/generators/thecore/companion_files.rb +142 -0
- data/lib/generators/thecore/member_action/member_action_generator.rb +61 -0
- data/lib/generators/thecore/member_action/templates/action.html.erb.tt +16 -0
- data/lib/generators/thecore/member_action/templates/action.js.tt +44 -0
- data/lib/generators/thecore/member_action/templates/action.rb.tt +25 -0
- data/lib/generators/thecore/member_action/templates/action.scss.tt +38 -0
- data/lib/generators/thecore/migration/migration_generator.rb +18 -7
- data/lib/generators/thecore/model/model_generator.rb +14 -0
- data/lib/generators/thecore/root_action/root_action_generator.rb +80 -0
- data/lib/generators/thecore/root_action/templates/action.html.erb.tt +13 -0
- data/lib/generators/thecore/root_action/templates/action.js.tt +42 -0
- data/lib/generators/thecore/root_action/templates/action.rb.tt +33 -0
- data/lib/generators/thecore/root_action/templates/action.scss.tt +38 -0
- data/lib/generators/thecore/workspace_context.rb +48 -0
- data/lib/tasks/thecore_generators_tasks.rake +33 -0
- data/lib/templates/app_template.rb +220 -0
- data/lib/thecore_generators/check_practices.rb +392 -0
- data/lib/thecore_generators/railtie.rb +4 -0
- data/lib/thecore_generators/version.rb +1 -1
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 43638f50febe1bf078edcf5e5490c33709065db0a522f2b14a659c9ae19954ab
|
|
4
|
+
data.tar.gz: 368b0b26a79a4a5be2ccc394e0dd4d64061efb1c66aa099d124432a1455e4230
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a0a7cb2fc271fe3f495ad2a4234ba338fc9e9b50f8309e2aefc593777d85fc8fa5b5146698f7000009a2da0552e1a89cd296e680a0003c99f6bca09e1b962e7e
|
|
7
|
+
data.tar.gz: 7495d392354b1f1834b8fba5117449f3b9383aa6df89f4bd637c9044bc7c3357f2136d316385b89057b2a851047887df7c49f94db5a7aa97641a91189ea05510
|
data/README.md
CHANGED
|
@@ -12,10 +12,23 @@ own `thecore:*`-namespaced generators or an application template. See
|
|
|
12
12
|
in the thecore repo for the full design.
|
|
13
13
|
|
|
14
14
|
**Status:** Model + Migration generator hook (Phase 1 of
|
|
15
|
-
[ADR 0002](https://github.com/gabrieletassoni/thecore/blob/release/3/docs/adr/0002-thecore-generators-gem-and-generator-hook-mechanism.md))
|
|
15
|
+
[ADR 0002](https://github.com/gabrieletassoni/thecore/blob/release/3/docs/adr/0002-thecore-generators-gem-and-generator-hook-mechanism.md)),
|
|
16
|
+
plus default-concern removal ([ADR 0001](https://github.com/gabrieletassoni/thecore/blob/release/3/docs/adr/0001-application-record-defaults-over-generated-concerns.md))
|
|
17
|
+
and migration-driven inverse-association wiring
|
|
18
|
+
([ADR 0003](https://github.com/gabrieletassoni/thecore/blob/release/3/docs/adr/0003-migration-driven-inverse-association-wiring.md)).
|
|
16
19
|
`ThecoreGenerators::Railtie` registers `config.app_generators.orm :thecore, migration:
|
|
17
20
|
true, timestamps: true`, so plain `rails generate model`/`rails generate migration`
|
|
18
|
-
transparently apply thecore's scaffolding conventions — no new command vocabulary.
|
|
21
|
+
transparently apply thecore's scaffolding conventions — no new command vocabulary. **Phase 2
|
|
22
|
+
(check_practices + Root/Member Action generators) is complete** as of this release: `rails
|
|
23
|
+
generate thecore:root_action`, `rails generate thecore:member_action`, and `rails
|
|
24
|
+
thecore:check_practices` (Scaffold Files + Models + Actions, `--fix` included) all ship.
|
|
25
|
+
**Phase 3's App application template** (porting `createApp.js`) is complete as of this release —
|
|
26
|
+
both the core (`lib/templates/app_template.rb`: Rails app + Gemfile stack + vendor placeholders,
|
|
27
|
+
thecore_generators#17) and devcontainer/CI/CLAUDE.md asset fetching from the thecore repo's own
|
|
28
|
+
samples (thecore_generators#18) have shipped. The `thecore:atom` generator and a Collection
|
|
29
|
+
Action generator remain deferred to a future session (ADR 0005). See
|
|
30
|
+
[ADR 0005](https://github.com/gabrieletassoni/thecore/blob/release/3/docs/adr/0005-app-template-scoped-to-rails-new-m-assets-sourced-from-thecore-samples.md)
|
|
31
|
+
in the thecore repo for the full design.
|
|
19
32
|
|
|
20
33
|
### What `rails generate model`/`rails generate migration` do now
|
|
21
34
|
|
|
@@ -104,6 +117,196 @@ model that has no concern of its own.
|
|
|
104
117
|
Either concern can be added independently — a model doesn't need both just because it
|
|
105
118
|
needs one.
|
|
106
119
|
|
|
120
|
+
### Inverse association wiring for `references` columns
|
|
121
|
+
|
|
122
|
+
Rails' own generators only ever wire the owning (`belongs_to`) side of a `references`/
|
|
123
|
+
`add_reference` column — the inverse `has_many`/`has_one` side has always been a manual
|
|
124
|
+
follow-up. `rails generate model`/`migration` now write that missing inverse side
|
|
125
|
+
automatically into the *target* model's own canonical concern
|
|
126
|
+
(`config/initializers/concern_<target_model>.rb`, wired up via
|
|
127
|
+
`config/initializers/after_initialize.rb`):
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
rails generate migration AddPostRefToComments post:references
|
|
131
|
+
# → prompts: "Inverse association on Post for this reference (has_many/has_one/skip)?"
|
|
132
|
+
# → writes `has_many :comments` into config/initializers/concern_post.rb
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
With a real terminal attached you're prompted for cardinality (`has_many` default, `has_one`,
|
|
136
|
+
or `skip`); pass `--non-interactive` (always used by `addModel`/`addMigration` in the VS Code
|
|
137
|
+
extension, and recommended for any other scripted/CI invocation) to skip the prompt and default
|
|
138
|
+
straight to `has_many`. Re-running the generator against the same target model never duplicates
|
|
139
|
+
an already-written association line, and a later, different reference onto the same target
|
|
140
|
+
appends into the same existing concern file. When the target model lives in a different ATOM
|
|
141
|
+
than the one being generated into, the concern is still written into the *invoking* app/ATOM
|
|
142
|
+
(never the target's own) and the generator logs — but never edits — the gemspec/Gemfile
|
|
143
|
+
dependency line a human needs to add so that include actually resolves. Full mechanics in
|
|
144
|
+
`CLAUDE.md`.
|
|
145
|
+
|
|
146
|
+
### `rails generate thecore:root_action NAME`
|
|
147
|
+
|
|
148
|
+
A Ruby port of `thecore_code_extension`'s `addRootAction.js` — produces the same end result
|
|
149
|
+
from a terminal, with the same ATOM-aware placement `rails generate model`/`migration` use:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
rails generate thecore:root_action my_action
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
This creates:
|
|
156
|
+
|
|
157
|
+
- The RailsAdmin action config file — `lib/root_actions/my_action.rb` in ATOM context,
|
|
158
|
+
`config/root_actions/my_action.rb` in host-app context (main-app actions never live under
|
|
159
|
+
`lib/`, for Zeitwerk autoload safety).
|
|
160
|
+
- Its view/JS/SCSS companions: `app/views/rails_admin/main/my_action.html.erb`,
|
|
161
|
+
`app/assets/javascripts/rails_admin/actions/my_action.js`,
|
|
162
|
+
`app/assets/stylesheets/rails_admin/actions/my_action.scss`.
|
|
163
|
+
- A `require` line inserted into `config/initializers/after_initialize.rb` (created from a
|
|
164
|
+
skeleton if absent) — `require 'root_actions/my_action'` in ATOM context, a full
|
|
165
|
+
`Rails.root.join(...)` require in host-app context (`config/` isn't on the load path).
|
|
166
|
+
- A precompile line inserted into `config/initializers/assets.rb` (created from a skeleton if
|
|
167
|
+
absent).
|
|
168
|
+
- An `admin.actions.my_action` locale entry (`menu`/`title`/`breadcrumb`, all set to the
|
|
169
|
+
title-cased action name) written into **every** `*.yml` file already present under
|
|
170
|
+
`config/locales` — `en.yml`/`it.yml` are created first only when the directory has none yet.
|
|
171
|
+
|
|
172
|
+
`NAME` must be snake_case (lowercase letters, digits, underscores). `--atom=NAME` overrides
|
|
173
|
+
placement the same way it does for `rails generate model`/`migration`. Re-running the
|
|
174
|
+
generator against the same action name never duplicates the require line, the precompile
|
|
175
|
+
line, or a locale entry.
|
|
176
|
+
|
|
177
|
+
The reusable pieces behind this (`Thecore::Generators::CompanionFiles`,
|
|
178
|
+
`Thecore::Generators::ActionCompanion`) are shared with `thecore:member_action` (below), and
|
|
179
|
+
`check_practices --fix` (further below) delegates straight to both generators' own template
|
|
180
|
+
rendering rather than reimplementing it.
|
|
181
|
+
|
|
182
|
+
### `rails generate thecore:member_action NAME`
|
|
183
|
+
|
|
184
|
+
The Member Action counterpart to `thecore:root_action` above — a Ruby port of
|
|
185
|
+
`thecore_code_extension`'s `addMemberAction.js`, sharing everything about placement, the
|
|
186
|
+
after_initialize.rb/assets.rb/locale mechanics, and even the generator implementation itself
|
|
187
|
+
(`Thecore::Generators::ActionCompanion`) with Root Action:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
rails generate thecore:member_action my_action
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Same file layout as Root Action (`lib/member_actions/`/`config/member_actions/`,
|
|
194
|
+
`app/views/rails_admin/main/my_action.html.erb`, `.../actions/my_action.js`/`.scss`,
|
|
195
|
+
after_initialize.rb require line, assets.rb precompile line, every-locale-file entry,
|
|
196
|
+
`--atom=NAME`, idempotent re-run). What differs is each action's own template content — not
|
|
197
|
+
unified between the two, matching `addRootAction.js`/`addMemberAction.js`'s own separate
|
|
198
|
+
templates exactly:
|
|
199
|
+
|
|
200
|
+
- **`action.rb`** (server-side, the real behavioral difference) — a RailsAdmin `:member`
|
|
201
|
+
action (`http_methods [:get, :patch]`) whose controller branches on `request.xhr? &&
|
|
202
|
+
request.get?` (returns JSON) vs. `request.patch?` (a form submission, redirects back to the
|
|
203
|
+
record), instead of Root's single `:root` action with a fetch/JSON + `ActionCable.server.broadcast`
|
|
204
|
+
example.
|
|
205
|
+
- **`action.js`/`action.html.erb`** — both still set up the same `ActivityLogChannel`
|
|
206
|
+
ActionCable subscription Root's do; only the test button's click handler differs (a plain
|
|
207
|
+
XHR `GET` here vs. `fetch` there), and the view adds a `form_with(..., method: :patch)` for
|
|
208
|
+
the PATCH half of the example.
|
|
209
|
+
|
|
210
|
+
### `rails thecore:check_practices`
|
|
211
|
+
|
|
212
|
+
A Ruby port of `thecore_code_extension`'s `checkPractices.js` — audits **Scaffold Files**
|
|
213
|
+
(thecore_generators#13), **Models** (thecore_generators#13), and **Actions**
|
|
214
|
+
(thecore_generators#14, `--fix` included).
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
rails thecore:check_practices # host app + every ATOM under vendor/submodules/
|
|
218
|
+
rails thecore:check_practices -- --atom=my_atom # scope to a single ATOM
|
|
219
|
+
rails thecore:check_practices -- --json # structured output for CI/the VS Code extension
|
|
220
|
+
rails thecore:check_practices -- --fix # apply every fixable violation, no confirmation
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The `--` before any flag is the standard Rake convention for passing arguments through to a
|
|
224
|
+
task instead of having Rake's own option parser reject them — see
|
|
225
|
+
[Rake's own docs](https://ruby.github.io/rake/doc/rakefile_rdoc.html#label-Task+Arguments).
|
|
226
|
+
Flags combine freely, e.g. `rails thecore:check_practices -- --atom=my_atom --fix --json`.
|
|
227
|
+
|
|
228
|
+
- **Scaffold Files** — `config/initializers/after_initialize.rb` and `assets.rb` must exist
|
|
229
|
+
and carry their structural marker (`Rails.application.configure do` /
|
|
230
|
+
`Rails.application.config.assets.precompile`). Checked in **both** ATOM and host-app
|
|
231
|
+
context (`checkPractices.js` only ever checked ATOM context). Not fixable.
|
|
232
|
+
- **Models** — rescoped per [ADR 0001](https://github.com/gabrieletassoni/thecore/blob/release/3/docs/adr/0001-application-record-defaults-over-generated-concerns.md):
|
|
233
|
+
a model with **no** `Api::`/`RailsAdmin::` concern is the correct, no-customization default
|
|
234
|
+
and is never flagged. Only two states are violations: a model `include`-ing a concern module
|
|
235
|
+
whose file doesn't exist (`orphan_api_include`/`orphan_rails_admin_include`), or a concern
|
|
236
|
+
file present but missing one of its required markers (`extend ActiveSupport::Concern` plus
|
|
237
|
+
`cattr_accessor :json_attrs` for `Api::`, `rails_admin do` for `RailsAdmin::`). Not fixable —
|
|
238
|
+
regenerating over an existing, hand-edited concern could clobber real customization.
|
|
239
|
+
- **Actions** — scans `root_actions`, `member_actions`, and `collection_actions` (in `lib/` for
|
|
240
|
+
an ATOM, `config/` for the host app), with the same rules for all three:
|
|
241
|
+
`collection_actions` is scanned even though no generator creates files there yet, since a
|
|
242
|
+
hand-written one could already exist and go unreported otherwise. Reports missing/broken
|
|
243
|
+
action-file markers (`RailsAdmin::Config::Actions.add_action`, `http_methods` — never
|
|
244
|
+
fixable), a missing companion view/JS/SCSS or one present but missing its own marker (a
|
|
245
|
+
*missing* companion is fixable for `root_action`/`member_action`, by delegating straight to
|
|
246
|
+
that generator's own template rendering — never for `collection_action`, which has no
|
|
247
|
+
generator to delegate to; an *existing* companion missing a marker is never fixable, same
|
|
248
|
+
reasoning as Models), a missing `after_initialize.rb` require line (fixable, all three
|
|
249
|
+
kinds), and a missing locale entry checked against **every** `*.yml` already present in the
|
|
250
|
+
locales directory, not just `en`/`it` (fixable, all three kinds).
|
|
251
|
+
|
|
252
|
+
Default output is human-readable text grouped by file; `--json` emits
|
|
253
|
+
`{ "violations": [{ "file", "line", "message", "severity", "fixable", "code" }] }` — `code` is
|
|
254
|
+
a stable identifier (e.g. `missing_after_initialize`, `orphan_api_include`,
|
|
255
|
+
`missing_companion_view`) a future consumer can filter on without depending on `message` text.
|
|
256
|
+
`--fix` applies every fixable violation in one pass with no confirmation of its own — whoever
|
|
257
|
+
passes it has already decided — then re-scans and reports/exits based on whatever violations
|
|
258
|
+
remain (so a violation this run can't fix, e.g. a `collection_action` companion, still shows up
|
|
259
|
+
after `--fix`). The task exits non-zero whenever any violation remains, zero otherwise, so it's
|
|
260
|
+
usable as a CI gate either with or without `--fix`.
|
|
261
|
+
|
|
262
|
+
### Application Template (`rails new -m`) (thecore_generators#17/#18)
|
|
263
|
+
|
|
264
|
+
A Ruby port of `thecore_code_extension`'s `createApp.js`, as a genuine Rails application
|
|
265
|
+
template rather than a `thecore:*` generator — its entry point is `rails new -m`, not `rails
|
|
266
|
+
generate`:
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
rails new myapp --database=postgresql --asset-pipeline=sprockets \
|
|
270
|
+
-m https://raw.githubusercontent.com/gabrieletassoni/thecore_generators/release/3/lib/templates/app_template.rb
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Run inside a devcontainer already created by the "Setup Devcontainer" VS Code command — a
|
|
274
|
+
bootstrap step this template doesn't invoke or modify itself (that command's own code is
|
|
275
|
+
untouched), even though the template's own devcontainer-asset fetch below does overwrite the
|
|
276
|
+
files that bootstrap step created, by design. See
|
|
277
|
+
[ADR 0005](https://github.com/gabrieletassoni/thecore/blob/release/3/docs/adr/0005-app-template-scoped-to-rails-new-m-assets-sourced-from-thecore-samples.md)
|
|
278
|
+
for the full design and why the two stay separate).
|
|
279
|
+
|
|
280
|
+
- **Core Gemfile stack, active** — `devise`, `cancancan`, `rails_admin`, `sassc-rails`,
|
|
281
|
+
`model_driven_api` (`~> 3.9`), `thecore_ui_rails_admin` (`~> 3.8` — both meeting ADR 0001's
|
|
282
|
+
`DefaultModuleRegistry` floor), `rails-erd` (`:development`). `thecore_generators` itself is
|
|
283
|
+
added too, `group: :development` — so the generated app can immediately use every
|
|
284
|
+
generator/task documented above without a manual Gemfile edit first.
|
|
285
|
+
- **The rest of the generic Thecore ecosystem, commented out** — `thecore_auth_commons`,
|
|
286
|
+
`thecore_settings`, `thecore_print_commons`, `thecore_background_jobs`, `thecore_ui_commons`,
|
|
287
|
+
`thecore_tcp_debug`, `thecore_download_documents`, `thecore_dataentry_commons`,
|
|
288
|
+
`thecore_connectors`, each with a one-line purpose comment — discoverable but off by default,
|
|
289
|
+
the same "commented but documented" philosophy ADR 0005 applies to the devcontainer's `gh`/
|
|
290
|
+
`glab` CLI mounts below.
|
|
291
|
+
- **`vendor/submodules/`/`vendor/external/`** — created empty (a `.keep` file each), not
|
|
292
|
+
pre-wired with any submodule or gem. Per ADR 0005 these are developer-convenience clone
|
|
293
|
+
locations, not template content.
|
|
294
|
+
- **Devcontainer/CI/CLAUDE.md**, fetched from the `thecore` repo's own `samples/` (single source
|
|
295
|
+
of truth, not duplicated here) and written unconditionally, overwriting whatever the bootstrap
|
|
296
|
+
"Setup Devcontainer" step created: `.devcontainer/*` (base image, plugin mounts, `gh`/`glab`
|
|
297
|
+
CLI config mounts commented out by default), `.gitlab-ci.yml` (build/test/lint/deploy, no
|
|
298
|
+
customer-specific paths), and `CLAUDE.md` (universal sections only, project-specific sections
|
|
299
|
+
left as TODO placeholders). The fetch location is one overridable point,
|
|
300
|
+
`ENV["THECORE_SAMPLES_SOURCE"]`, defaulting to the raw GitHub URL for `thecore`'s `samples/`
|
|
301
|
+
on `master` (`thecore`'s actual default branch).
|
|
302
|
+
- **The standard installer chain** (`devise:install`, `rails_admin:install`, `active_storage:
|
|
303
|
+
install`, `action_text:install`, `action_mailbox:install`, `cancan:ability`, `erd:install`,
|
|
304
|
+
each preceded by the necessary `bundle install`) is genuinely optional, gated behind an
|
|
305
|
+
interactive prompt (`yes?`, wrapped in `after_bundle` so it only ever runs once the gems
|
|
306
|
+
above are actually bundled) — a developer bootstrapping without network access can decline
|
|
307
|
+
and run these by hand later. There is no non-interactive/unattended flag for this in the
|
|
308
|
+
current version (tracked as a future improvement, not silently missing).
|
|
309
|
+
|
|
107
310
|
## Installation
|
|
108
311
|
|
|
109
312
|
Add to your host app's or ATOM's `Gemfile`:
|
|
@@ -142,6 +345,16 @@ To run a single test file:
|
|
|
142
345
|
bundle exec ruby -Itest test/generators/thecore/model_generator_test.rb
|
|
143
346
|
```
|
|
144
347
|
|
|
348
|
+
## Who depends on this
|
|
349
|
+
|
|
350
|
+
The host backend app's own `Gemfile` (`:development` group) and the
|
|
351
|
+
[Thecore VS Code extension](https://github.com/gabrieletassoni/thecore_code_extension)'s
|
|
352
|
+
`addModel`/`addMigration` commands both depend on this gem being present to get Thecore-aware
|
|
353
|
+
`rails generate` behavior — the extension actually checks for it and offers to add it
|
|
354
|
+
automatically if it's missing, since without it `rails generate model`/`migration` still "work"
|
|
355
|
+
but silently skip every convention described above. See `CLAUDE.md`'s "Who consumes this gem"
|
|
356
|
+
section for detail.
|
|
357
|
+
|
|
145
358
|
## Releasing
|
|
146
359
|
|
|
147
360
|
Version lives in `lib/thecore_generators/version.rb`. Pushing a commit that bumps it
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
module Thecore
|
|
2
|
+
module Generators
|
|
3
|
+
# Shared helper logic for Thecore's own per-action generators
|
|
4
|
+
# (RootActionGenerator - thecore_generators#11, MemberActionGenerator -
|
|
5
|
+
# thecore_generators#12): everything about them that is NOT the action
|
|
6
|
+
# file's own RailsAdmin action type/template content, and not a Thor
|
|
7
|
+
# *task* method itself.
|
|
8
|
+
#
|
|
9
|
+
# Thor::Group (which Rails::Generators::Base/NamedBase extends)
|
|
10
|
+
# discovers its task list via a `method_added` hook that only fires for
|
|
11
|
+
# methods defined directly, via `def`, in the generator class's own
|
|
12
|
+
# body - never for methods a class merely picks up through `include`.
|
|
13
|
+
# So each of `validate_action_name!`/`create_action_file`/
|
|
14
|
+
# `create_view_js_scss_companions`/`add_after_initialize_require`/
|
|
15
|
+
# `add_assets_precompile_line`/`add_locale_entries` must still be a
|
|
16
|
+
# thin method defined directly on RootActionGenerator/
|
|
17
|
+
# MemberActionGenerator themselves (identical one-liners in both); only
|
|
18
|
+
# the private logic those methods delegate to lives here.
|
|
19
|
+
#
|
|
20
|
+
# Extracted once both generators existed and turned out identical apart
|
|
21
|
+
# from that thin task-method layer and their directory name
|
|
22
|
+
# ("root_actions"/"member_actions") - not a speculative abstraction
|
|
23
|
+
# built ahead of a second user.
|
|
24
|
+
#
|
|
25
|
+
# The including class must declare `action_kind "root_action"` (or
|
|
26
|
+
# `"member_action"`) at the class body level, which derives both the
|
|
27
|
+
# directory name (pluralized: "root_actions"/"member_actions") and the
|
|
28
|
+
# wording used in the name-validation error message.
|
|
29
|
+
module ActionCompanion
|
|
30
|
+
def self.included(base)
|
|
31
|
+
base.extend(ClassMethods)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Shared require-line text for an action's `after_initialize.rb`
|
|
35
|
+
# registration - `lib/<kind>s/<name>` in ATOM context (on the load
|
|
36
|
+
# path via the gemspec), a full `Rails.root.join('config', ...)` path
|
|
37
|
+
# in host-app context (`config/` isn't on the load path). A plain
|
|
38
|
+
# module method (not routed through `ClassMethods`/`extend`, unlike
|
|
39
|
+
# `action_kind` below) so it's callable directly as
|
|
40
|
+
# `Thecore::Generators::ActionCompanion.require_line_for(...)` without
|
|
41
|
+
# an including generator instance - used both by `require_line` below
|
|
42
|
+
# (via a real generator's own `atom_dir`/`file_name`) and directly by
|
|
43
|
+
# `Thecore::CheckPractices`' Actions check, so the audit's own
|
|
44
|
+
# expectation can never drift out of sync with what a real Root/Member
|
|
45
|
+
# Action generator actually writes.
|
|
46
|
+
def self.require_line_for(kind:, in_atom:, name:)
|
|
47
|
+
dir_name = "#{kind}s"
|
|
48
|
+
if in_atom
|
|
49
|
+
"require '#{dir_name}/#{name}'"
|
|
50
|
+
else
|
|
51
|
+
"require Rails.root.join('config', '#{dir_name}', '#{name}').to_s"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A class-level accessor, not an instance `define_method`, and
|
|
56
|
+
# deliberately so: any *instance* method this DSL defined directly on
|
|
57
|
+
# the including generator class - even a private one, since Thor's
|
|
58
|
+
# `method_added` hook fires and registers it as a task at the moment
|
|
59
|
+
# `define_method` returns, before a later `private` call could demote
|
|
60
|
+
# it - would itself become a spurious Thor task (this was tried and
|
|
61
|
+
# caught via `RootActionGenerator.all_tasks.keys` including
|
|
62
|
+
# "action_kind" as an actual, if harmless, generator step). Storing
|
|
63
|
+
# the value in a class-level ivar instead, read back via
|
|
64
|
+
# `self.class.action_kind` from the private instance methods below,
|
|
65
|
+
# adds no method to the generator class's own instance side at all.
|
|
66
|
+
module ClassMethods
|
|
67
|
+
def action_kind(value = nil)
|
|
68
|
+
@action_kind = value unless value.nil?
|
|
69
|
+
@action_kind
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def validate_action_name_for_kind!
|
|
76
|
+
return if name.match?(/\A[a-z_][a-z0-9_]*\z/)
|
|
77
|
+
|
|
78
|
+
raise Thor::Error,
|
|
79
|
+
"'#{name}' is not a valid #{self.class.action_kind.tr("_", " ")} name - use " \
|
|
80
|
+
"snake_case, starting with a lowercase letter or underscore (a leading digit would " \
|
|
81
|
+
"make the generated `topic: :#{name}` symbol invalid Ruby)."
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def action_dir_name
|
|
85
|
+
"#{self.class.action_kind}s"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# lib/<action_dir_name> in ATOM context (on the load path via the
|
|
89
|
+
# gemspec), config/<action_dir_name> in host-app context (not on the
|
|
90
|
+
# load path, hence the full-path require in `require_line` below) -
|
|
91
|
+
# see docs/adr/0001-main-app-actions-live-in-config.md in
|
|
92
|
+
# thecore_code_extension.
|
|
93
|
+
def action_file_path
|
|
94
|
+
base_dir = atom_dir ? "lib" : "config"
|
|
95
|
+
File.join(base_dir, action_dir_name, "#{file_name}.rb")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def require_line
|
|
99
|
+
ActionCompanion.require_line_for(kind: self.class.action_kind, in_atom: !atom_dir.nil?, name: file_name)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def assets_precompile_line
|
|
103
|
+
"Rails.application.config.assets.precompile += %w( rails_admin/actions/#{file_name}.js " \
|
|
104
|
+
"rails_admin/actions/#{file_name}.css )"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def action_name_camel_case
|
|
108
|
+
file_name.downcase.gsub(/[-_]([a-z0-9])/) { Regexp.last_match(1).upcase }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def title_case_name
|
|
112
|
+
file_name.split("_").map(&:capitalize).join(" ")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
require "generators/thecore/workspace_context"
|
|
2
|
+
|
|
3
|
+
module Thecore
|
|
4
|
+
module Generators
|
|
5
|
+
# Implements ADR 0003 in the thecore repo
|
|
6
|
+
# (docs/adr/0003-migration-driven-inverse-association-wiring.md):
|
|
7
|
+
# Rails' own migration generator only ever wires the owning (`belongs_to`)
|
|
8
|
+
# side of a `references`/`add_reference` column — the inverse
|
|
9
|
+
# (`has_many`/`has_one`) has always been manual. This module detects
|
|
10
|
+
# `references` attributes on the migration being generated and writes the
|
|
11
|
+
# missing inverse side into the *target* model's canonical per-model
|
|
12
|
+
# concern, following the cross-ATOM extension pattern GUIDE.md §4.5
|
|
13
|
+
# already documents (`config/initializers/concern_<model>.rb` plus a
|
|
14
|
+
# `TargetModel.send(:include, ...)` registered in
|
|
15
|
+
# `config/initializers/after_initialize.rb`).
|
|
16
|
+
#
|
|
17
|
+
# Included by both Thecore::Generators::MigrationGenerator (standalone
|
|
18
|
+
# `rails generate migration ... x:references`) and
|
|
19
|
+
# Thecore::Generators::ModelGenerator (`rails generate model Foo
|
|
20
|
+
# x:references` delegates its own migration creation to
|
|
21
|
+
# ActiveRecord::Generators::ModelGenerator#create_migration_file, a
|
|
22
|
+
# *different* method than MigrationGenerator's own, so both host classes
|
|
23
|
+
# need their own hook). Both wire it the same way: override
|
|
24
|
+
# `create_migration_file`, call `super`, then
|
|
25
|
+
# `wire_inverse_associations_from_references` — matching the
|
|
26
|
+
# override-and-call-super style Thecore::Generators::ModelGenerator
|
|
27
|
+
# already uses for `create_model_file`/`add_default_concerns`.
|
|
28
|
+
#
|
|
29
|
+
# Requires the includer to already provide (directly or via
|
|
30
|
+
# Thecore::Generators::AtomAware, included by both host classes):
|
|
31
|
+
# - `attributes` — Rails::Generators::GeneratedAttribute array (from
|
|
32
|
+
# ActiveRecord's own migration/model generator `argument`)
|
|
33
|
+
# - `table_name` — Rails::Generators::NamedBase; memoized into
|
|
34
|
+
# @table_name, which for MigrationGenerator is populated by
|
|
35
|
+
# ActiveRecord's own `set_local_assigns!` (run during `super`) from
|
|
36
|
+
# the migration's file name (`add_x_to_y`/`create_y`), and for
|
|
37
|
+
# ModelGenerator resolves directly from the model's own class name -
|
|
38
|
+
# either way it names the table/model *gaining* the reference.
|
|
39
|
+
# - `destination_root`/`host_app_root` — Thecore::Generators::AtomAware
|
|
40
|
+
module AssociationWiring
|
|
41
|
+
HEADER_COMMENT = <<~RUBY.freeze
|
|
42
|
+
# This file is maintained by thecore_generators (migration generator's
|
|
43
|
+
# inverse-association wiring - see
|
|
44
|
+
# docs/adr/0003-migration-driven-inverse-association-wiring.md in the
|
|
45
|
+
# thecore repo). Associations below are appended automatically whenever
|
|
46
|
+
# a `references` column targeting this model is added elsewhere.
|
|
47
|
+
# Do not hand-edit the generated section: a future generator run may
|
|
48
|
+
# append to it again, and hand edits are not accounted for.
|
|
49
|
+
RUBY
|
|
50
|
+
|
|
51
|
+
AFTER_INITIALIZE_TEMPLATE = <<~RUBY.freeze
|
|
52
|
+
Rails.application.configure do
|
|
53
|
+
config.after_initialize do
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
RUBY
|
|
57
|
+
|
|
58
|
+
def self.included(base)
|
|
59
|
+
base.class_option :non_interactive, type: :boolean, default: false,
|
|
60
|
+
desc: "Skip the inverse-association cardinality prompt and default to has_many " \
|
|
61
|
+
"(no real TTY is available to CI or scripted invocations, e.g. the VS Code " \
|
|
62
|
+
"extension)"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Entry point, called after `super` from the includer's own
|
|
68
|
+
# `create_migration_file` override.
|
|
69
|
+
def wire_inverse_associations_from_references
|
|
70
|
+
return if options[:migration] == false # ModelGenerator's --no-migration
|
|
71
|
+
return if attributes.nil?
|
|
72
|
+
|
|
73
|
+
owning_table_name = table_name
|
|
74
|
+
return if owning_table_name.blank?
|
|
75
|
+
|
|
76
|
+
reference_attributes = attributes.select(&:reference?)
|
|
77
|
+
return if reference_attributes.empty?
|
|
78
|
+
|
|
79
|
+
reference_attributes.each { |attribute| wire_inverse_association(owning_table_name, attribute) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def wire_inverse_association(owning_table_name, attribute)
|
|
83
|
+
target_class_name = attribute.singular_name.camelize
|
|
84
|
+
cardinality = prompt_cardinality_for(target_class_name)
|
|
85
|
+
return if cardinality == :skip
|
|
86
|
+
|
|
87
|
+
association_line = association_line_for(cardinality, owning_table_name)
|
|
88
|
+
write_target_concern(target_class_name, association_line)
|
|
89
|
+
register_after_initialize(target_class_name)
|
|
90
|
+
log_cross_boundary_dependency(target_class_name)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def association_line_for(cardinality, owning_table_name)
|
|
94
|
+
if cardinality == :has_one
|
|
95
|
+
"has_one :#{owning_table_name.singularize}"
|
|
96
|
+
else
|
|
97
|
+
"has_many :#{owning_table_name}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def prompt_cardinality_for(target_class_name)
|
|
102
|
+
return :has_many unless interactive_association_prompt?
|
|
103
|
+
|
|
104
|
+
answer = ask(
|
|
105
|
+
"Inverse association on #{target_class_name} for this reference " \
|
|
106
|
+
"(has_many/has_one/skip)?",
|
|
107
|
+
default: "has_many",
|
|
108
|
+
limited_to: %w[has_many has_one skip]
|
|
109
|
+
)
|
|
110
|
+
answer.to_s.strip.to_sym
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# No real TTY is present for CI/scripted/extension invocations (or the
|
|
114
|
+
# includer was explicitly told not to prompt via --non-interactive) -
|
|
115
|
+
# default straight to has_many, per ADR 0003.
|
|
116
|
+
def interactive_association_prompt?
|
|
117
|
+
!options[:non_interactive] && $stdin.tty? && $stdout.tty?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def concern_path_for(target_class_name)
|
|
121
|
+
"config/initializers/concern_#{target_class_name.underscore}.rb"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def concern_module_name_for(target_class_name)
|
|
125
|
+
"Concern#{target_class_name}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def write_target_concern(target_class_name, association_line)
|
|
129
|
+
concern_path = concern_path_for(target_class_name)
|
|
130
|
+
|
|
131
|
+
if File.exist?(File.join(destination_root, concern_path))
|
|
132
|
+
insert_association_into_existing_concern(concern_path, association_line)
|
|
133
|
+
else
|
|
134
|
+
create_concern_file(concern_path, concern_module_name_for(target_class_name), association_line)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def create_concern_file(concern_path, concern_module_name, association_line)
|
|
139
|
+
content = <<~RUBY
|
|
140
|
+
#{HEADER_COMMENT}
|
|
141
|
+
module #{concern_module_name}
|
|
142
|
+
extend ActiveSupport::Concern
|
|
143
|
+
|
|
144
|
+
included do
|
|
145
|
+
#{association_line}
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
RUBY
|
|
149
|
+
|
|
150
|
+
create_file(concern_path, content)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def insert_association_into_existing_concern(concern_path, association_line)
|
|
154
|
+
full_path = File.join(destination_root, concern_path)
|
|
155
|
+
content = File.read(full_path)
|
|
156
|
+
|
|
157
|
+
if content.match?(/^\s*#{Regexp.escape(association_line)}\s*$/)
|
|
158
|
+
say_status :skip, "#{concern_path} already has `#{association_line}`", :blue
|
|
159
|
+
return
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
insert_into_file(concern_path, " #{association_line}\n", after: /included do\n/)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def register_after_initialize(target_class_name)
|
|
166
|
+
path = "config/initializers/after_initialize.rb"
|
|
167
|
+
full_path = File.join(destination_root, path)
|
|
168
|
+
|
|
169
|
+
create_file(path, AFTER_INITIALIZE_TEMPLATE) unless File.exist?(full_path)
|
|
170
|
+
|
|
171
|
+
registration_line = "#{target_class_name}.send(:include, #{concern_module_name_for(target_class_name)})"
|
|
172
|
+
content = File.read(full_path)
|
|
173
|
+
return if content.include?(registration_line)
|
|
174
|
+
|
|
175
|
+
insert_into_file(path, " #{registration_line}\n", after: /config\.after_initialize do\n/)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Cross-boundary case (ADR 0003): the concern always lives in the
|
|
179
|
+
# invoking app/ATOM (destination_root), never the target model's own -
|
|
180
|
+
# so when the target model actually lives elsewhere, the generated
|
|
181
|
+
# `TargetModel.send(:include, ...)` line depends on that model's
|
|
182
|
+
# constant being loaded, which requires a real gem/path dependency a
|
|
183
|
+
# human has to add. We only log it — never edit a gemspec/Gemfile.
|
|
184
|
+
def log_cross_boundary_dependency(target_class_name)
|
|
185
|
+
target_root = Thecore::Generators::WorkspaceContext.model_root_for(
|
|
186
|
+
class_name: target_class_name, app_root: host_app_root
|
|
187
|
+
)
|
|
188
|
+
return if target_root.nil? || target_root == destination_root
|
|
189
|
+
|
|
190
|
+
own_is_atom = destination_root != host_app_root
|
|
191
|
+
target_is_atom = target_root != host_app_root
|
|
192
|
+
own_label = own_is_atom ? File.basename(destination_root) : "the host app"
|
|
193
|
+
target_label = target_is_atom ? File.basename(target_root) : "the host app"
|
|
194
|
+
|
|
195
|
+
message = "Cross-boundary reference: #{target_class_name} lives in #{target_label}, but the " \
|
|
196
|
+
"generated concern was written into #{own_label} (ADR 0003: the concern always lives in the " \
|
|
197
|
+
"invoking app/ATOM). #{dependency_hint(own_is_atom, target_is_atom, target_root)}"
|
|
198
|
+
|
|
199
|
+
say_status :dependency, message, :yellow
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def dependency_hint(own_is_atom, target_is_atom, target_root)
|
|
203
|
+
target_gem_name = File.basename(target_root)
|
|
204
|
+
|
|
205
|
+
if own_is_atom && target_is_atom
|
|
206
|
+
"Add a gem dependency in this ATOM's gemspec, e.g.: spec.add_dependency \"#{target_gem_name}\""
|
|
207
|
+
elsif !own_is_atom && target_is_atom
|
|
208
|
+
"Add a Gemfile dependency in the host app, e.g.: gem \"#{target_gem_name}\", " \
|
|
209
|
+
"path: \"vendor/submodules/#{target_gem_name}\""
|
|
210
|
+
else
|
|
211
|
+
"The target model lives in the host app; confirm this ATOM is meant to reference host-app " \
|
|
212
|
+
"code directly (no gemspec/Gemfile dependency line applies here)."
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -30,21 +30,32 @@ module Thecore
|
|
|
30
30
|
|
|
31
31
|
def initialize(*args)
|
|
32
32
|
super
|
|
33
|
+
@host_app_root = destination_root
|
|
33
34
|
self.destination_root = atom_dir if atom_dir
|
|
34
35
|
end
|
|
35
36
|
|
|
37
|
+
# The true host-app root, captured before `destination_root` is
|
|
38
|
+
# (possibly) overridden above — for a real `rails generate` invocation
|
|
39
|
+
# this is `Rails::Command.root`, in tests whatever
|
|
40
|
+
# `Rails::Generators::TestCase` configured as `destination_root`.
|
|
41
|
+
# Unlike `destination_root` (which becomes the ATOM dir once
|
|
42
|
+
# overridden), this always stays the app root, giving
|
|
43
|
+
# Thecore::Generators::WorkspaceContext.model_root_for a stable anchor
|
|
44
|
+
# to search from regardless of where *this* invocation itself landed.
|
|
45
|
+
attr_reader :host_app_root
|
|
46
|
+
|
|
36
47
|
# The absolute ATOM directory this generator's files should land in, or
|
|
37
48
|
# nil for plain host-app context. Resolved once, before
|
|
38
49
|
# `destination_root` is (possibly) overridden above, since the
|
|
39
|
-
# pre-override `destination_root` is the correct
|
|
40
|
-
# resolving an explicit `--atom=NAME`.
|
|
50
|
+
# pre-override `destination_root` (== `host_app_root`) is the correct
|
|
51
|
+
# app-root anchor for resolving an explicit `--atom=NAME`.
|
|
41
52
|
def atom_dir
|
|
42
53
|
return @atom_dir if @atom_dir_resolved
|
|
43
54
|
|
|
44
55
|
@atom_dir_resolved = true
|
|
45
56
|
@atom_dir = Thecore::Generators::WorkspaceContext.atom_dir_for(
|
|
46
57
|
cwd: Dir.pwd,
|
|
47
|
-
app_root:
|
|
58
|
+
app_root: host_app_root,
|
|
48
59
|
atom_name: options[:atom]
|
|
49
60
|
)
|
|
50
61
|
end
|