permittable 0.8.0 → 0.10.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.
@@ -3,8 +3,9 @@ require "ripper"
3
3
  module Permittable
4
4
  # Drafts a permit_params contract from what the app already knows: the
5
5
  # model's columns (types, NOT NULL, database defaults) and, when the
6
- # controller source is available, the strong-parameters calls already in it
7
- # (`params.require(:user).permit(:name, tags: [])`). The draft is a
6
+ # controller source is available, the params calls already in it — both
7
+ # spellings, `params.require(:user).permit(:name, tags: [])` and Rails 8's
8
+ # `params.expect(user: [:name, tags: []])`. The draft is a
8
9
  # STARTING POINT, not an oracle — everything the generator cannot know for
9
10
  # sure is marked with a TODO comment instead of guessed, and the whole
10
11
  # contract is emitted in monitor mode so pasting it changes nothing until
@@ -21,6 +22,27 @@ module Permittable
21
22
  DEFAULT_ACTIONS = %i[create update].freeze
22
23
  SKIPPED_COLUMNS = %w[created_at updated_at].freeze
23
24
 
25
+ # One column as the drafting code sees it: the database facts, plus what
26
+ # the MODEL layers on top — an enum accessor that changes what a client
27
+ # sends (`enum_integers` when it stores Integers), a default the model
28
+ # fills in, or a `sensitive` role (:sti, :locking) that makes the column
29
+ # dangerous to leave client-writable. `default` is the comment describing
30
+ # whichever default applies, and `unread` the error that stopped the
31
+ # model's enum and default from being read.
32
+ # Building these once in columns_for keeps every drafting path (columns
33
+ # alone, or a scan typed from columns) reading the same answers.
34
+ #
35
+ # `rule` and `listed` are how column_line is told what it is drafting
36
+ # for: the rule (see render) and whether the controller's permit call
37
+ # lists the column. They ride on the column, set by in_rule, so the scan
38
+ # path hands them to column_line without its own methods changing.
39
+ DraftColumn = Struct.new(:name, :type, :null, :default, :default_function, :enum, :enum_integers, :sensitive,
40
+ :unread, :rule, :listed, keyword_init: true) do
41
+ def in_rule(rule, listed: false)
42
+ self.class.new(**to_h, rule: rule, listed: listed)
43
+ end
44
+ end
45
+
24
46
  # Column type => contract type. Document-shaped columns map onto the
25
47
  # opaque `:json` field — the shape stays undeclared, which is what a
26
48
  # jsonb column is for, and `max_depth:`/`length:` can bound it later.
@@ -34,14 +56,34 @@ module Permittable
34
56
  json: :json, jsonb: :json, hstore: :json
35
57
  }.freeze
36
58
 
37
- # What a source scan recovered from existing permit calls. `scalars` are
38
- # plain `:key` arguments, `arrays` are `key: []`, `nested` maps `key:
39
- # [:a, :b]` onto its sub-keys, and `unparsed` keeps verbatim anything the
40
- # conservative parser would otherwise have silently dropped.
41
- Scan = Struct.new(:root, :scalars, :arrays, :nested, :unparsed, :calls, keyword_init: true) do
59
+ # What a source scan recovered from the `params.permit` and Rails 8
60
+ # `params.expect` calls already in a controller. `scalars` are plain
61
+ # `:key` arguments, `arrays` are `key: []`, `nested` maps `key: [:a, :b]`
62
+ # onto its sub-keys, `nested_arrays` maps the `key: [[:a, :b]]` an
63
+ # `expect` call spells an array of hashes with, and `unparsed` keeps
64
+ # verbatim anything the conservative parser would otherwise have silently
65
+ # dropped. `conflicts` names each key permitted in more than one shape
66
+ # (see resolve_conflicts), and `undecided` lists the keys among them
67
+ # drafted as no shape at all. What parsed fine but belongs outside the
68
+ # chosen root's contract is kept apart from `unparsed`, so its TODO can
69
+ # say why: `route_params` holds what is never a body field (a single-key
70
+ # expect lookup, and what an expect call spells beside its envelope);
71
+ # `rootless` holds the keys of the rootless calls once an envelope won —
72
+ # a filter or a body, the scan cannot tell; `other_envelopes` maps each
73
+ # losing root onto its calls, spelled as the source spells them. `calls`
74
+ # counts the params calls found.
75
+ Scan = Struct.new(:root, :scalars, :arrays, :nested, :nested_arrays, :unparsed, :conflicts, :undecided,
76
+ :route_params, :rootless, :other_envelopes, :calls, keyword_init: true) do
42
77
  def found?
43
78
  calls.positive?
44
79
  end
80
+
81
+ # Whether the chosen root's calls parsed into any field. A draft checks
82
+ # its lines instead (Generator.declares_field?): a scanned key whose
83
+ # column has no contract type is a field here but only a TODO there.
84
+ def fields?
85
+ [scalars, arrays, nested, nested_arrays].any? { |shape| !shape.empty? }
86
+ end
45
87
  end
46
88
 
47
89
  # One permit call, with an optional leading `.require(:root)`. The args
@@ -50,11 +92,59 @@ module Permittable
50
92
  # half-read.
51
93
  PERMIT_CALL = /params\s*(?:\.\s*require\(\s*:(\w+)\s*\))?\s*\.\s*permit\(([^()]*)\)/m
52
94
 
95
+ # One Rails 8 `params.expect` call — the replacement for
96
+ # `require(...).permit(...)`, and the reason this scanner exists twice: a
97
+ # Rails 8 controller has no permit calls to read, so without this the
98
+ # generator would fall back to columns alone and lose everything the app
99
+ # already knows about its own params. Same conservative capture as
100
+ # PERMIT_CALL: brackets and newlines are fine, a parenthesis means a
101
+ # method call in the arguments and the whole call is skipped rather than
102
+ # half-read.
103
+ EXPECT_CALL = /params\s*\.\s*expect\(([^()]*)\)/m
104
+
105
+ # The required root envelope of an expect call: `user: [...]`, where the
106
+ # brackets hold fields — not the empty `tag_names: []` of an
107
+ # array-of-scalars root, and not the `comments: [[...]]` of an
108
+ # array-of-hashes root, neither of which a rooted contract can express.
109
+ # The lookahead skips whitespace itself: `\[\s*(?!...)` let `\s*` match
110
+ # nothing and waved the spaced `tag_names: [ ]` through as an envelope
111
+ # with no fields.
112
+ EXPECT_ENVELOPE = /\A(\w+):\s*\[(?!\s*[\[\]])\s*(.*?)\s*\]\z/m
113
+
114
+ # An envelope whose fields live in a constant: `post: PERMITTED_PARAMS`.
115
+ # The fields cannot be read, but the root can, and read as a rootless
116
+ # argument it let a `params.permit(:page)` beside it take the root.
117
+ EXPECT_CONSTANT_ENVELOPE = /\A(\w+):\s*((?:::)?[A-Z]\w*(?:::[A-Z]\w*)*)\z/
118
+
119
+ # The key of a single-key rootless EXPECT lookup that is a route param,
120
+ # not input: the Rails 8 scaffold's `Post.find(params.expect(:id))`, or a
121
+ # nested resource's `params.expect(:post_id)`. Only an expect call with
122
+ # that one key counts: `params.expect(:id, :q)` is a filter that names an
123
+ # id, and `params.permit(:group_id)` is mass assignment, not a lookup.
124
+ ROUTE_PARAM_KEY = /\A(?:id|\w+_id)\z/
125
+
53
126
  # A permit key: `:name`, `"name"`, or `'name'` (quotes must match —
54
127
  # anything else stays unparsed rather than guessed).
55
128
  SCALAR_KEY = /\A(?::(\w+)|"(\w+)"|'(\w+)')\z/
56
129
  ARRAY_ARG = /\A(\w+):\s*\[\s*\]\z/m
57
130
  NESTED_ARG = /\A(\w+):\s*\[([^\[\]]*)\]\z/m
131
+ # expect-only: `comments: [[:body, :author]]` is an array of hashes.
132
+ NESTED_ARRAY_ARG = /\A(\w+):\s*\[\s*\[([^\[\]]*)\]\s*\]\z/m
133
+
134
+ # The shapes a scanned key can take, richest first, with how a conflict
135
+ # TODO names each (see resolve_conflicts).
136
+ SHAPES = {
137
+ nested_arrays: "an array of hashes", nested: "a nested hash", arrays: "an array", scalars: "a scalar"
138
+ }.freeze
139
+ HASH_SHAPES = %i[nested nested_arrays].freeze
140
+
141
+ # One params call, or one envelope of an expect call, at its offset in
142
+ # the source. `route_params` are never body fields, whichever root wins:
143
+ # the arguments an expect call spells beside its envelope, and the key of
144
+ # a single-key route-param lookup (ROUTE_PARAM_KEY). `spelling` is how an
145
+ # envelope's TODO quotes it if it loses.
146
+ Call = Struct.new(:position, :root, :fields, :route_params, :spelling)
147
+ private_constant :Call
58
148
 
59
149
  # Comment tokens. Ripper (stdlib) is used rather than a regexp because `#`
60
150
  # is only a comment sometimes — it also appears inside string literals and
@@ -63,6 +153,16 @@ module Permittable
63
153
  # spelling, and its keys live in string tokens.
64
154
  COMMENT_TOKENS = %i[on_comment on_embdoc on_embdoc_beg on_embdoc_end].freeze
65
155
 
156
+ # A string/heredoc/regexp literal's CONTENT, as opposed to the Ruby
157
+ # syntax around it. Used by masked_source — see there for why this is
158
+ # kept apart from comments rather than treated the same way.
159
+ STRING_CONTENT_TOKENS = %i[on_tstring_content].freeze
160
+
161
+ # What a masked string/heredoc/regexp content token's characters become,
162
+ # except `(`/`)` (see mask_content): not a word character, so `params`,
163
+ # `permit`, `require`, and `expect` can never spell out of it.
164
+ MASK_CHAR = "\u0000".freeze
165
+
66
166
  module_function
67
167
 
68
168
  # `source` with its comments removed. A controller keeping a commented-out
@@ -74,44 +174,211 @@ module Permittable
74
174
  # syntactically odd file scans exactly as it did before rather than not at
75
175
  # all.
76
176
  def executable_source(source)
77
- tokens = Ripper.lex(source)
78
- return source if tokens.nil? || tokens.empty?
177
+ tokens = code_tokens(source)
178
+ return source unless tokens
79
179
 
80
- tokens.reject { |token| COMMENT_TOKENS.include?(token[1]) }.map { |token| token[2] }.join
180
+ tokens.map { |token| token[2] }.join
81
181
  rescue StandardError
82
182
  source
83
183
  end
84
184
 
85
- # Merge every permit call found in `source` into one Scan. The first
86
- # `.require(:root)` seen wins, matching how a controller normally sticks
87
- # to one envelope across actions.
88
- def scan(source)
89
- result = Scan.new(root: nil, scalars: [], arrays: [], nested: {}, unparsed: [], calls: 0)
90
- executable_source(source.to_s).scan(PERMIT_CALL) do |root, args|
91
- result.calls += 1
92
- result.root ||= root&.to_sym
93
- split_args(args).each { |arg| classify_arg(result, arg) }
94
- end
185
+ # executable_source with every string/heredoc/regexp literal's CONTENT
186
+ # run through mask_content, same length so a MatchData's offsets against
187
+ # this text still locate the same characters in executable_source.
188
+ #
189
+ # A call spelled out as TEXT inside a string — a log line quoting
190
+ # `params.require(:admin).permit(:superuser)` for humans — has no
191
+ # `params`, `permit`, `require`, or `expect` left once masked, so
192
+ # PERMIT_CALL and EXPECT_CALL can no longer match it there; the same
193
+ # conflation as a `#` comment (see executable_source), just reached
194
+ # through a string literal instead. A REAL call's own string argument
195
+ # (`permit("name")`) still matches: `params`, `.`, `permit`, and the
196
+ # parens around it are code tokens, never string content, so masking
197
+ # never touches them — only the argument text inside the parens is
198
+ # masked here, and scan reads that text back out of executable_source by
199
+ # offset rather than off this string.
200
+ def masked_source(source)
201
+ tokens = code_tokens(source)
202
+ return source unless tokens
203
+
204
+ tokens.map { |token| STRING_CONTENT_TOKENS.include?(token[1]) ? mask_content(token[2]) : token[2] }.join
205
+ rescue StandardError
206
+ source
207
+ end
208
+
209
+ # One string/heredoc/regexp content token, masked: every character
210
+ # becomes MASK_CHAR except `(`/`)`, which are left alone. Keeping parens
211
+ # literal costs nothing PERMIT_CALL/EXPECT_CALL look for — the words
212
+ # they require are gone either way — and keeps a call already open
213
+ # before the string closing on the same paren it always did: a lenient
214
+ # lex can swallow real code into an unterminated string's content, exactly
215
+ # as a stray `"` does today (see the "mismatched quotes" scan spec), and
216
+ # masking every character there would eat the `)` that call is
217
+ # conservatively parsed with.
218
+ def mask_content(text)
219
+ text.gsub(/[^()]/, MASK_CHAR)
220
+ end
221
+
222
+ # The lexed tokens shared by executable_source and masked_source, with
223
+ # comments already dropped — nil when Ripper could not lex `source` at
224
+ # all, or found nothing.
225
+ def code_tokens(source)
226
+ tokens = Ripper.lex(source)
227
+ return nil if tokens.nil? || tokens.empty?
228
+
229
+ tokens.reject { |token| COMMENT_TOKENS.include?(token[1]) }
230
+ end
231
+
232
+ # Merge every `params.permit` and `params.expect` call found in the
233
+ # EXECUTABLE part of `source` into one Scan, under one root (see
234
+ # choose_root), matching how a controller normally sticks to one
235
+ # envelope across actions. Comments are stripped first for both call shapes — a
236
+ # commented-out `expect` is no more code than a commented-out `permit`.
237
+ #
238
+ # The root is chosen from ALL the calls before any field is merged,
239
+ # because one rooted contract can express only one envelope. A Rails 8
240
+ # scaffold's `Post.find(params.expect(:id))` in `set_post`, or an index
241
+ # action's `params.permit(:page)`, sits beside `params.expect(post: [...])`
242
+ # and is a route param or a filter, not a body field — merged in, it was
243
+ # drafted as `optional :id` inside the `post` envelope. So only the chosen
244
+ # root's calls become fields; another envelope, and a rootless call once
245
+ # there is an envelope, stay visible as TODOs. A file of only rootless
246
+ # calls is a filter contract and still drafts them as fields.
247
+ #
248
+ # The calls are read in SOURCE order, whichever spelling each uses:
249
+ # scanning every permit call before every expect call made "first seen"
250
+ # mean "first permit call", so a later search form could outrank the
251
+ # expect envelope above it. The sort is made stable by the index, because
252
+ # the envelopes of one expect call share a position and `sort_by` alone
253
+ # may reorder them.
254
+ #
255
+ # `model:` names the envelope a Rails form for that model sends; see
256
+ # choose_root. `exclude:` lists roots (nil for the rootless calls) that
257
+ # may not be chosen — see for_controller.
258
+ def scan(source, model: nil, exclude: [])
259
+ result = Scan.new(root: nil, scalars: [], arrays: [], nested: {}, nested_arrays: {}, unparsed: [],
260
+ conflicts: [], undecided: [], route_params: [], rootless: [], other_envelopes: {}, calls: 0)
261
+ raw = source.to_s
262
+ source = executable_source(raw)
263
+ masked = masked_source(raw)
264
+ permits = matches(masked, PERMIT_CALL).map { |match| permit_call(source, match) }
265
+ expects = matches(masked, EXPECT_CALL).map { |match| expect_calls(match.begin(0), split_args(group(source, match, 1))) }
266
+ result.calls = permits.size + expects.size
267
+ calls = (permits + expects).flatten.sort_by.with_index { |call, index| [call.position, index] }
268
+ result.root = choose_root(calls, model&.name && default_root(model), exclude)
269
+ calls.each { |call| merge_call(result, call) }
270
+ resolve_conflicts(result)
271
+ drop_drafted_todos(result)
95
272
  result
96
273
  end
97
274
 
275
+ def matches(source, pattern)
276
+ source.to_enum(:scan, pattern).map { Regexp.last_match }
277
+ end
278
+
279
+ # The real text under one of masked_source's MatchData groups, read back
280
+ # out of `source` (executable_source, not masked_source) at the same
281
+ # offsets — nil when the group did not participate in the match. See
282
+ # masked_source for why a matched call's own text lives in `source`
283
+ # rather than in the MatchData itself.
284
+ def group(source, match, index)
285
+ return nil unless match.begin(index)
286
+
287
+ source[match.begin(index)...match.end(index)]
288
+ end
289
+
98
290
  # Draft a contract for one controller: model inferred from
99
291
  # controller_name (or passed explicitly), permit calls scanned from
100
292
  # `source:` when given. Returns nil when there is nothing to draft from.
293
+ #
294
+ # When the chosen root cannot be drafted at all — the model's envelope
295
+ # permits only a binary column, and the model has no column a contract
296
+ # can declare — the next candidate root is tried rather than returning
297
+ # nil, until one drafts or none is left: a draft rooted at the other
298
+ # envelope in the file is a better starting point than no draft, and it
299
+ # is what master drafted.
101
300
  def for_controller(controller, source: nil, model: nil)
102
- draft(model: model || infer_model(controller), scan: scan(source))
301
+ model ||= infer_model(controller)
302
+ excluded = []
303
+ loop do
304
+ scan = scan(source, model: model, exclude: excluded)
305
+ result = draft(model: model, scan: scan)
306
+ return result if result || !scan.found? || excluded.include?(scan.root)
307
+
308
+ excluded << scan.root
309
+ end
103
310
  end
104
311
 
105
312
  # The core: knowledge in (columns and/or a scan), snippet out. Returns a
106
- # String of valid Ruby, or nil when neither source of knowledge exists.
313
+ # String of valid Ruby, or nil when neither source of knowledge exists —
314
+ # or when neither can declare a single field, since a contract of only
315
+ # TODO lines raises `a contract must declare at least one field` the
316
+ # moment it is pasted.
107
317
  def draft(model: nil, scan: nil)
108
318
  columns = columns_for(model)
109
319
  scan = nil unless scan&.found?
110
320
  return nil unless columns || scan
321
+ return column_draft(model, columns.values, []) unless scan
322
+
323
+ shared = scanned_lines(scan, listed_columns(columns, :shared))
324
+ return fallback_draft(model, scan, columns) unless declares_field?(shared)
325
+
326
+ render(root: scan.root, model: columns && model) { |rule| scanned_lines(scan, listed_columns(columns, rule)) }
327
+ end
111
328
 
112
- root = scan ? scan.root : default_root(model)
113
- body = scan ? scanned_lines(scan, columns) : column_lines(columns.values)
114
- render(signature(root: root, model: columns && model), body)
329
+ # The columns as one rule drafts them (see DraftColumn#in_rule), each
330
+ # marked listed: on the scan path a column is drafted only because the
331
+ # controller's own calls name it.
332
+ def listed_columns(columns, rule)
333
+ columns&.transform_values { |column| column.in_rule(rule, listed: true) }
334
+ end
335
+
336
+ # Whether any line declares a field. Scan#fields? cannot answer this
337
+ # alone: a scanned key whose column has no contract type (binary,
338
+ # geometry) drafts only a TODO.
339
+ def declares_field?(lines)
340
+ lines.any? { |line| !line.start_with?("#") }
341
+ end
342
+
343
+ # A scan that found calls but no fields — `permit(*PERMITTED)`, or every
344
+ # call belonging to another envelope — drafted a contract with only TODO
345
+ # lines, which raises `a contract must declare at least one field` the
346
+ # moment it is pasted. The columns are the next best knowledge, so they
347
+ # are drafted instead, with the scan's TODOs kept beneath them — minus
348
+ # any column the scan's TODOs say is not drafted: a key permitted in
349
+ # shapes that accept different input, and a route param.
350
+ #
351
+ # The scan's rootlessness is kept only when the rootless calls carried a
352
+ # body field (see rootless_body?): a rootless `params.permit(*KEYS)`
353
+ # controller drafted under the model's root would answer 400 to every
354
+ # request it already serves once enforced. A "rootless" scan that found
355
+ # only a route-param lookup — `Post.find(params.expect(:id))` beside a
356
+ # `permit(policy(@post).permitted_attributes)` the scanner skips — says
357
+ # nothing about the body, so it gets the model's root, as master drafted.
358
+ # With no columns either there is nothing loadable to draft, so nil.
359
+ def fallback_draft(model, scan, columns)
360
+ return nil unless columns
361
+
362
+ root = scan.root || (rootless_body?(scan) ? nil : default_root(model))
363
+ undrafted = (Array(scan.undecided) + Array(scan.route_params).filter_map { |arg| scalar_key(arg) }).map(&:to_s)
364
+ drafted = columns.except(*undrafted)
365
+ # As in drop_drafted_todos: a rootless key the columns now declare is
366
+ # not also a "not in this contract" TODO.
367
+ scan = scan.dup.tap { |copy| copy.rootless = Array(scan.rootless).reject { |arg| drafted.key?(scalar_key(arg).to_s) } }
368
+ column_draft(model, drafted.values, scan_todo_lines(scan), root: root)
369
+ end
370
+
371
+ # Whether a rootless scan's calls carried at least one body field —
372
+ # parsed, unparsable, or permitted in conflicting shapes. Route-param
373
+ # lookups are filed under `route_params`, not here.
374
+ def rootless_body?(scan)
375
+ scan.fields? || !scan.unparsed.empty? || !Array(scan.conflicts).empty?
376
+ end
377
+
378
+ # A draft from the columns, or nil when none of them has a contract type
379
+ # to declare it with (render returns nil for a body of comments only).
380
+ def column_draft(model, columns, todos, root: default_root(model))
381
+ render(root: root, model: model) { |rule| column_lines(columns.map { |column| column.in_rule(rule) }) + todos }
115
382
  end
116
383
 
117
384
  def infer_model(controller)
@@ -125,17 +392,175 @@ module Permittable
125
392
  # is no model or its schema is unreachable (same philosophy as the drift
126
393
  # guard: never let generation crash on a half-migrated database).
127
394
  def columns_for(model)
395
+ columns = schema_columns(model)
396
+ columns&.to_h { |column| [column.name, draft_column(model, column)] }
397
+ end
398
+
399
+ # Only schema access is rescued here. The model introspection layered on
400
+ # top (draft_column) must not share this rescue: an error there would
401
+ # otherwise discard EVERY column — the draft silently falls back to a
402
+ # scan alone, or to nothing — for what is one column's problem.
403
+ #
404
+ # The rescue is scoped to ActiveRecord::ActiveRecordError, same as
405
+ # ColumnGuard.schema_reachable? and for the same reason: a genuinely
406
+ # unreachable schema (no database yet, table not migrated) degrades to
407
+ # no columns, but a real bug — a broken custom type adapter, a NameError
408
+ # from a typo — must keep surfacing instead of quietly emitting an empty
409
+ # draft. The defined? guard keeps this gem loadable without
410
+ # activerecord, same as schema_reachable? (a host without it duck-types
411
+ # `model:` and cannot raise an ActiveRecordError in the first place).
412
+ def schema_columns(model)
128
413
  return nil unless model.respond_to?(:columns)
129
414
  return nil unless model.table_exists?
130
415
 
131
416
  # Array() flattens a composite primary key (an Array in Rails 7.1+)
132
417
  # into its column names; a nil primary key becomes [].
133
418
  skipped = SKIPPED_COLUMNS + Array(model.primary_key).map(&:to_s)
134
- model.columns.reject { |c| skipped.include?(c.name) }.to_h { |c| [c.name, c] }
135
- rescue StandardError
419
+ model.columns.reject { |c| skipped.include?(c.name) }
420
+ rescue StandardError => e
421
+ raise unless defined?(ActiveRecord::ActiveRecordError) && e.is_a?(ActiveRecord::ActiveRecordError)
422
+
136
423
  nil
137
424
  end
138
425
 
426
+ # The sensitive role is read OUTSIDE model_facts' rescue: it only
427
+ # compares the column's name with the model's STI and locking settings,
428
+ # and a failed enum or default read must not also make `type` or
429
+ # `lock_version` client-writable.
430
+ def draft_column(model, column)
431
+ DraftColumn.new(name: column.name, type: column.type, null: column.null,
432
+ default_function: column.respond_to?(:default_function) && column.default_function,
433
+ sensitive: sensitive_role(model, column.name), **model_facts(model, column))
434
+ end
435
+
436
+ # What the model adds to one column. An error reading it degrades THAT
437
+ # column to its database facts, with the error in a TODO — rather than
438
+ # raising, which would abort permittable:generate for every controller
439
+ # over one odd model, or dropping the column, which would hide it. The
440
+ # message is squished because it lands in a one-line comment, where a
441
+ # newline would end the comment and leave the draft unparseable.
442
+ def model_facts(model, column)
443
+ enum = enum_for(model, column.name)
444
+ { enum: enum && enum[:accessor], enum_integers: enum && enum[:mapping].values.all?(Integer),
445
+ default: default_note(model, column, enum) }
446
+ rescue StandardError => e
447
+ { default: database_default_note(column, nil), unread: "#{e.class}: #{e.message}".squish }
448
+ end
449
+
450
+ # The default Rails actually applies, as a comment. One the MODEL
451
+ # declares — `attribute :carrier, default: "post"`, `enum ..., default:
452
+ # :pending` — never reaches the schema, yet it fills the field on create
453
+ # just as a database default does, so it too keeps a NOT NULL column
454
+ # from being `required`. It wins over the database's, as it does in
455
+ # Rails.
456
+ def default_note(model, column, enum)
457
+ declared = model_default(model, column.name, enum)
458
+ declared ? "model default: #{declared}" : database_default_note(column, enum)
459
+ end
460
+
461
+ # An enum's database default is its stored integer; shown as its key
462
+ # (`"pending"`, not `0`), which is what the drafted field accepts.
463
+ def database_default_note(column, enum)
464
+ default = column.default
465
+ return nil if default.nil?
466
+
467
+ default = enum[:mapping].find { |_key, value| value.to_s == default.to_s }&.first || default if enum
468
+ "database default: #{default.inspect}"
469
+ end
470
+
471
+ # `_default_attributes` (nodoc, but what `column_defaults` is built from
472
+ # in every supported Rails, 6.1–8.1) holds a UserProvidedDefault for each
473
+ # default the model declares; the rest came from the database. Read per
474
+ # attribute rather than through column_defaults, which evaluates every
475
+ # Proc default at once.
476
+ #
477
+ # The note is written from the default AS DECLARED (its private
478
+ # `user_provided_value`, the same in 6.1–8.1), never from `value`:
479
+ # casting runs the attribute type's code, which is the app's, and can
480
+ # raise — `attribute :prefs, :json, default: {}` does. A Proc is not
481
+ # called either: drafting must not run app code with side effects, and
482
+ # today's value says nothing about tomorrow's. An enum default declared
483
+ # by its stored value is shown as its key, like a database one.
484
+ def model_default(model, name, enum)
485
+ return nil unless model.respond_to?(:_default_attributes) && defined?(ActiveModel::Attribute::UserProvidedDefault)
486
+
487
+ attribute = model._default_attributes[name]
488
+ return nil unless attribute.is_a?(ActiveModel::Attribute::UserProvidedDefault)
489
+
490
+ declared = attribute.send(:user_provided_value)
491
+ return "computed by a Proc" if declared.is_a?(Proc)
492
+ return nil if declared.nil?
493
+
494
+ declared = enum[:mapping].key(declared) || declared if enum
495
+ default_literal(declared)
496
+ end
497
+
498
+ # How a declared default reads in a comment: strings and Symbols (an
499
+ # enum key given as `:pending`) quoted, numbers and times as people
500
+ # write them — `1.5` rather than BigDecimal's `0.15e1`. Anything else is
501
+ # inspected and squished, since the note must stay on one line.
502
+ def default_literal(value)
503
+ case value
504
+ when String, Symbol then value.to_s.inspect
505
+ when BigDecimal then value.to_s("F")
506
+ when Numeric then value.to_s
507
+ else value.respond_to?(:strftime) ? value.to_s : value.inspect.squish
508
+ end
509
+ end
510
+
511
+ # A Rails enum stores an integer but is ASSIGNED its key: a form sends
512
+ # "shipped", never 1, so drafting the column type (:integer) would reject
513
+ # every legitimate request. The draft references the model's own
514
+ # accessor rather than inlining today's keys, so adding a value to the
515
+ # enum cannot leave the contract rejecting it.
516
+ #
517
+ # Rails defines the accessor for any enum name, but only an identifier
518
+ # can be CALLED as `Model.name`: a `first-status` enum's
519
+ # `Order.first-statuses.keys` parses as `Order.first - statuses.keys`,
520
+ # which runs a query when the draft loads. defined_enums reaches the
521
+ # same mapping by name. The model is also checked for the accessor
522
+ # itself (mirrors ColumnGuard.enum_keys_expr): a name can pluralize to a
523
+ # valid identifier that the model does not actually answer to — renamed
524
+ # or otherwise excluded — and calling it would raise NoMethodError when
525
+ # the draft loads.
526
+ def enum_for(model, name)
527
+ return nil unless model.respond_to?(:defined_enums)
528
+
529
+ mapping = model.defined_enums[name]
530
+ return nil unless mapping
531
+
532
+ plural = name.pluralize
533
+ accessor = if METHOD_NAME.match?(plural) && model.respond_to?(plural)
534
+ "#{model.name}.#{plural}"
535
+ else
536
+ "#{model.name}.defined_enums[#{name.inspect}]"
537
+ end
538
+ { mapping: mapping, accessor: accessor }
539
+ end
540
+
541
+ METHOD_NAME = /\A[a-z_][a-zA-Z0-9_]*\z/
542
+
543
+ # The role that makes a column dangerous for a client to write — :sti,
544
+ # :locking, or nil. What the draft then does with it is column_line's
545
+ # call, since that depends on whether the permit call lists it.
546
+ def sensitive_role(model, name)
547
+ # Only a model that actually uses STI: the inheritance column is set
548
+ # (not nil) AND exists. Mass-assigning it changes which class the
549
+ # record is loaded as — `type: "Admin"` on a signup form.
550
+ return :sti if model.respond_to?(:inheritance_column) && model.inheritance_column.to_s == name
551
+
552
+ :locking if locking_column?(model, name)
553
+ end
554
+
555
+ # Mirrors the STI check's respect for `inheritance_column = nil`: with
556
+ # `self.lock_optimistically = false` Rails never reads or bumps the
557
+ # column, so it is an ordinary integer and drafts as one.
558
+ def locking_column?(model, name)
559
+ return false unless model.respond_to?(:locking_column) && model.locking_column.to_s == name
560
+
561
+ !model.respond_to?(:lock_optimistically) || model.lock_optimistically
562
+ end
563
+
139
564
  # -- scan parsing -------------------------------------------------------
140
565
 
141
566
  # Split a permit argument list on top-level commas only, so `address:
@@ -153,23 +578,231 @@ module Permittable
153
578
  parts.map(&:strip).reject(&:empty?)
154
579
  end
155
580
 
581
+ # An expect call's arguments, as one Call per envelope. The bracketed
582
+ # arguments are required root envelopes and their contents are fields.
583
+ # Plain symbols are fields only when there is no envelope
584
+ # (`params.expect(:q, :page)` is a rootless filter); alongside one they
585
+ # are route params rather than body fields, so they stay visible instead
586
+ # of being drafted as contract fields. Each envelope is its own candidate
587
+ # root: `expect(post: [...], comment: [...])` can be where the comment
588
+ # contract's fields live, and reading only the first envelope hid them.
589
+ def expect_calls(position, args)
590
+ envelopes, others = args.partition { |arg| envelope(arg) }
591
+ return [expect_rootless_call(position, others)] if envelopes.empty?
592
+
593
+ envelopes.each_with_index.map do |arg, index|
594
+ root, fields = envelope(arg)
595
+ Call.new(position, root, fields, index.zero? ? others : [], unparsed_arg(arg))
596
+ end
597
+ end
598
+
599
+ # An expect argument as `[root, fields]` when it is an envelope, else nil.
600
+ # A constant envelope's one "field" is the constant, kept as unparsable.
601
+ def envelope(arg)
602
+ if (match = EXPECT_ENVELOPE.match(arg))
603
+ [match[1].to_sym, split_args(match[2])]
604
+ elsif (match = EXPECT_CONSTANT_ENVELOPE.match(arg))
605
+ [match[1].to_sym, [match[2]]]
606
+ end
607
+ end
608
+
609
+ # A rooted permit call is quoted as the call itself, on one line, so its
610
+ # TODO can be found in the source it came from. `source` is
611
+ # executable_source: `match` was found by scanning masked_source, whose
612
+ # own text may hold placeholders rather than a real string argument's
613
+ # characters (see masked_source), so every group is read back out of
614
+ # `source` by position instead of off `match` directly.
615
+ def permit_call(source, match)
616
+ args = split_args(group(source, match, 2))
617
+ root = group(source, match, 1)
618
+ return Call.new(match.begin(0), nil, args, []) unless root
619
+
620
+ spelling = unparsed_arg(group(source, match, 0)).gsub(/\(\s+/, "(").gsub(/\s+\)/, ")")
621
+ Call.new(match.begin(0), root.to_sym, args, [], spelling)
622
+ end
623
+
624
+ # A rootless expect call — or, when its one key is a route param
625
+ # (ROUTE_PARAM_KEY), a lookup with no fields at all, so it can neither
626
+ # score toward the root nor be drafted as a field when the rootless calls
627
+ # win.
628
+ def expect_rootless_call(position, args)
629
+ route = args.size == 1 && (key = scalar_key(args.first)) && ROUTE_PARAM_KEY.match?(key.to_s)
630
+ route ? Call.new(position, nil, [], args) : Call.new(position, nil, args, [])
631
+ end
632
+
633
+ # The model's own envelope wins outright when one of the calls uses it
634
+ # (`preferred`, derived like default_root): it is the envelope a Rails
635
+ # form for the model sends, and the only way to root a
636
+ # `require(:post).permit(*PERMITTED)` that has no field to score with.
637
+ #
638
+ # With no model to say which envelope is the form's, an envelope with a
639
+ # parsed field beats the rootless calls, as any envelope always did: an
640
+ # index action's `params.permit(:page, :per_page)` must not take the root
641
+ # from a one-field `post_params` on a guess. An envelope with NO parsed
642
+ # field — `expect(search: FILTERS)` — only beats rootless calls that have
643
+ # none either: beside `params.permit(:title, :body)` it would root a draft
644
+ # with nothing to declare, where master drafted title and body.
645
+ #
646
+ # Otherwise the candidate — an envelope, or, with a model that no
647
+ # envelope matches, the rootless calls together (root nil) — declaring
648
+ # the most distinct PARSED fields across its calls wins. Now that
649
+ # the losers become TODOs rather than fields, "first seen" alone let an
650
+ # index action's `require(:search).permit(:q)` win the root and push the
651
+ # real `expect(post: [...])` into a TODO; and considering envelopes only
652
+ # let that same search form beat a rootless `params.permit(:title, :body,
653
+ # :published)` carrying the whole create body. Unparsable arguments
654
+ # (`*PERMITTED`) count for nothing: they are not fields the draft can
655
+ # declare.
656
+ #
657
+ # A tie goes to an envelope, then to the first seen in the source. The
658
+ # envelope wins a tie because a rootless key beside one is usually a
659
+ # route or query param: a one-field Rails 8 scaffold calls
660
+ # `params.expect(:id)` in `set_post` above `params.expect(post: [:title])`.
661
+ def choose_root(calls, preferred = nil, exclude = [])
662
+ calls = calls.reject { |call| exclude.include?(call.root) }
663
+ return preferred if preferred && calls.any? { |call| call.root == preferred }
664
+
665
+ scores = calls.group_by(&:root).transform_values do |same|
666
+ same.flat_map { |call| call.fields.filter_map { |arg| parse_arg(arg)&.at(1) } }.uniq.size
667
+ end
668
+ scores = envelopes_first(scores) unless preferred
669
+ best = scores.each_with_index.max_by { |(root, score), index| [score, root ? 1 : 0, -index] }
670
+ best&.first&.first
671
+ end
672
+
673
+ # The model-less rule above: the rootless candidate is dropped when an
674
+ # envelope has a parsed field, or when it has none itself.
675
+ def envelopes_first(scores)
676
+ envelopes = scores.except(nil)
677
+ return scores if envelopes.empty?
678
+
679
+ envelopes.values.max.positive? || scores.fetch(nil, 0).zero? ? envelopes : scores
680
+ end
681
+
682
+ # Fold one call into the scan: its fields when it shares the scan's root
683
+ # (including both having none), a TODO otherwise — its arguments filed
684
+ # under its own root, or as rootless keys, so the TODO says where they
685
+ # belong rather than that they could not be read.
686
+ def merge_call(result, call)
687
+ if call.root == result.root
688
+ call.fields.each { |arg| classify_arg(result, arg) }
689
+ elsif call.root
690
+ result.other_envelopes[call.root] = (result.other_envelopes[call.root] || []) | [call.spelling]
691
+ else
692
+ result.rootless |= call.fields.map { |arg| unparsed_arg(arg) }
693
+ end
694
+ result.route_params |= call.route_params.map { |arg| unparsed_arg(arg) }
695
+ end
696
+
697
+ # A key the winning root drafts as a field is not also a TODO: beside
698
+ # `expect(post: [:title, :group_id])`, a `Group.find(params.expect(:group_id))`
699
+ # lookup or a rootless `params.permit(:group_id)` would otherwise say
700
+ # "not a body field" about a field the draft declares. Only a bare key is
701
+ # dropped: a shaped copy (`tags: [:z]` beside the envelope's `tags: [:a]`)
702
+ # may carry sub-keys the drafted field lacks, and dropping its TODO would
703
+ # lose them.
704
+ def drop_drafted_todos(result)
705
+ drafted = SHAPES.keys.flat_map { |shape| shape_keys(result, shape) }
706
+ %i[route_params rootless].each do |member|
707
+ result[member] = result[member].reject { |arg| drafted.include?(scalar_key(arg)) }
708
+ end
709
+ end
710
+
711
+ # A key permitted in two shapes across actions is declared once — a
712
+ # contract rejects a field declared twice — and how depends on whether
713
+ # one shape accepts what the other is sent:
714
+ #
715
+ # - a nested hash and an array of hashes merge into the array of hashes,
716
+ # with the sub-keys of both: `key: [[:a]]` is expect's definitive
717
+ # spelling of the array of hashes that `key: [:a]` leaves ambiguous,
718
+ # and dropping the loser's sub-keys would reject the ones its action
719
+ # sends;
720
+ # - an array of scalars and either hash shape accept disjoint input, so
721
+ # neither is drafted — picking one would reject what the other action
722
+ # sends — and the TODO names both;
723
+ # - anything else goes to the richer shape (SHAPES is ordered richest
724
+ # first), the one at least one action demonstrably accepts: a scalar
725
+ # declaration would reject the hash or array that action is sent.
726
+ def resolve_conflicts(result)
727
+ SHAPES.keys.flat_map { |shape| shape_keys(result, shape) }.uniq.each do |key|
728
+ shapes = SHAPES.keys.select { |shape| shape_keys(result, shape).include?(key) }
729
+ next if shapes.size < 2
730
+
731
+ result.conflicts << resolve_conflict(result, key, shapes)
732
+ end
733
+ end
734
+
735
+ def resolve_conflict(result, key, shapes)
736
+ merged = (HASH_SHAPES - shapes).empty?
737
+ result.nested_arrays[key] |= result.nested[key] if merged
738
+ if shapes.include?(:arrays) && shapes.intersect?(HASH_SHAPES)
739
+ shapes.each { |shape| result[shape].delete(key) }
740
+ result.undecided << key
741
+ return "#{key} is permitted as #{listed_shapes(shapes)}, which accept different input — " \
742
+ "drafted as #{shapes.size == 2 ? 'neither' : 'none of them'}; declare the shape its actions share"
743
+ end
744
+
745
+ shapes.drop(1).each { |shape| result[shape].delete(key) }
746
+ "#{key} is permitted as #{listed_shapes(shapes)} — drafted as #{the_shape(shapes.first)}" \
747
+ "#{merge_note(shapes) if merged}"
748
+ end
749
+
750
+ # A merge keeps the nested hash's sub-keys, so only a scalar is dropped.
751
+ def merge_note(shapes)
752
+ " with the nested hash's sub-keys merged in#{', dropping the scalar' if shapes.include?(:scalars)}"
753
+ end
754
+
755
+ def the_shape(shape)
756
+ SHAPES[shape].sub(/\Aan? /, "the ")
757
+ end
758
+
759
+ def shape_keys(result, shape)
760
+ keys = result[shape]
761
+ keys.is_a?(Hash) ? keys.keys : keys
762
+ end
763
+
764
+ # "both a scalar and an array", "a scalar, a nested hash and an array of
765
+ # hashes" — poorest first.
766
+ def listed_shapes(shapes)
767
+ names = shapes.reverse.map { |shape| SHAPES[shape] }
768
+ names.size == 2 ? "both #{names.join(' and ')}" : "#{names[0..-2].join(', ')} and #{names.last}"
769
+ end
770
+
156
771
  def classify_arg(result, arg)
772
+ shape, key, sub_keys = parse_arg(arg)
773
+ if shape.nil?
774
+ result.unparsed |= [unparsed_arg(arg)]
775
+ elsif sub_keys
776
+ result[shape][key] = (result[shape][key] || []) | sub_keys
777
+ else
778
+ result[shape] |= [key]
779
+ end
780
+ end
781
+
782
+ # One permit argument as `[shape, key]`, or `[shape, key, sub_keys]` for
783
+ # the two hash shapes — nil when the conservative parser cannot read it,
784
+ # including a nested list with any sub-key it cannot read.
785
+ def parse_arg(arg)
157
786
  if (key = scalar_key(arg))
158
- result.scalars |= [key]
787
+ [:scalars, key]
159
788
  elsif (match = ARRAY_ARG.match(arg))
160
- result.arrays |= [match[1].to_sym]
789
+ [:arrays, match[1].to_sym]
790
+ elsif (match = NESTED_ARRAY_ARG.match(arg))
791
+ nested_arg(:nested_arrays, match)
161
792
  elsif (match = NESTED_ARG.match(arg))
162
- classify_nested(result, match, arg)
163
- else
164
- result.unparsed |= [arg.gsub(/\s+/, " ")]
793
+ nested_arg(:nested, match)
165
794
  end
166
795
  end
167
796
 
168
- def classify_nested(result, match, arg)
169
- keys = split_args(match[2]).map { |part| scalar_key(part) }
170
- return result.unparsed |= [arg.gsub(/\s+/, " ")] if keys.any?(&:nil?)
797
+ # An empty list (`meta: [[ ]]`, `meta: [ , ]`) is unparsable too: it
798
+ # would draft a `do end` block, which the DSL rejects.
799
+ def nested_arg(shape, match)
800
+ sub_keys = split_args(match[2]).map { |part| scalar_key(part) }
801
+ [shape, match[1].to_sym, sub_keys] unless sub_keys.empty? || sub_keys.any?(&:nil?)
802
+ end
171
803
 
172
- result.nested[match[1].to_sym] = (result.nested[match[1].to_sym] || []) | keys
804
+ def unparsed_arg(arg)
805
+ arg.gsub(/\s+/, " ")
173
806
  end
174
807
 
175
808
  def scalar_key(part)
@@ -179,12 +812,17 @@ module Permittable
179
812
 
180
813
  # -- drafting -----------------------------------------------------------
181
814
 
815
+ # model_name.param_key is the key Rails form helpers submit under and
816
+ # `params.require` reads — `blog_post` for Blog::Post, where demodulizing
817
+ # the class name gave `post` and an enforced draft 400'd every submit.
182
818
  def default_root(model)
819
+ return model.model_name.param_key.to_sym if model.respond_to?(:model_name)
820
+
183
821
  model.name.demodulize.underscore.to_sym
184
822
  end
185
823
 
186
- def signature(root:, model:)
187
- parts = ["permit_params #{DEFAULT_ACTIONS.map(&:inspect).join(', ')}"]
824
+ def signature(root:, model:, actions: DEFAULT_ACTIONS)
825
+ parts = ["permit_params #{actions.map(&:inspect).join(', ')}"]
188
826
  parts << "root: :#{root}" if root
189
827
  parts << "model: #{model.name}" if model
190
828
  parts << "mode: :monitor do"
@@ -195,13 +833,77 @@ module Permittable
195
833
  columns.map { |column| column_line(column) }
196
834
  end
197
835
 
836
+ # One column's line in one rule — `column.rule` is :shared (a single
837
+ # :create, :update rule), :create, or :update, which never requires
838
+ # anything (see render). `column.listed` says the controller's own
839
+ # permit call lists the column, which decides what a sensitive column
840
+ # becomes.
841
+ #
842
+ # Names are emitted with Symbol#inspect, so a column called `first-name`
843
+ # or `2fa_enabled` drafts as `:"first-name"` rather than as Ruby that
844
+ # does not parse.
198
845
  def column_line(column)
199
- type = COLUMN_TYPES[column.type]
846
+ return "# TODO: #{column.name} #{omitted_note(column)}" if column.sensitive && !column.listed
847
+
848
+ type = column.enum ? :string : COLUMN_TYPES[column.type]
200
849
  return "# TODO: #{column.name} (#{column.type}) has no contract type — declare it as a nested block or an array" unless type
201
850
 
202
- line = "#{required_column?(column) ? 'required' : 'optional'} :#{column.name}, :#{type}"
203
- line += " # database default: #{column.default.inspect}" unless column.default.nil?
204
- line
851
+ required = column.rule != :update && required_column?(column)
852
+ line = "#{required ? 'required' : 'optional'} #{column.name.to_sym.inspect}, :#{type}"
853
+ line += ", in: #{column.enum}.keys" if column.enum
854
+ notes = [column.default, *column_todos(column)].compact
855
+ notes.empty? ? line : "#{line} # #{notes.join('; ')}"
856
+ end
857
+
858
+ # Drafting from columns alone, a sensitive column is left out of the
859
+ # fields — nothing says any client sends it — and named here instead.
860
+ # The lock_version note says where to declare it in the rules actually
861
+ # drafted: stale-update detection is an update's concern, so a split
862
+ # :create rule points at the :update rule rather than at itself.
863
+ def omitted_note(column)
864
+ return STI_OMITTED if column.sensitive == :sti
865
+
866
+ where = column.rule == :create ? "in the :update rule below" : "here"
867
+ "is the optimistic-locking column — Rails increments it on every save; if your edit forms round-trip it " \
868
+ "as a hidden field for stale-update detection, declare `optional #{column.name.to_sym.inspect}, :integer` #{where}"
869
+ end
870
+
871
+ STI_OMITTED = "is the STI inheritance column — assigning it changes the record's class, so no client should " \
872
+ "send it; if clients really pick the subclass, declare it with in: the allowed class names".freeze
873
+
874
+ # The TODOs a drafted column line carries. A sensitive column the permit
875
+ # call lists stays a field — omitting lock_version there would silently
876
+ # switch off the stale-update detection the app wired up, the moment the
877
+ # draft is enforced — but says why it deserves a second look.
878
+ def column_todos(column)
879
+ todos = []
880
+ todos << "TODO: #{column.name} #{SCANNED_SENSITIVE.fetch(column.sensitive)}" if column.sensitive
881
+ todos << enum_todo(column) if column.enum_integers
882
+ todos << unread_todo(column) if column.unread
883
+ todos
884
+ end
885
+
886
+ SCANNED_SENSITIVE = {
887
+ locking: "is the optimistic-locking column — kept because the permit call lists it: an edit form that " \
888
+ "round-trips it is how Rails detects a stale update; never give it a default:",
889
+ sti: "is the STI inheritance column — kept because the permit call lists it, but assigning it changes the " \
890
+ "record's class: restrict it with in: the subclass names a client may pick"
891
+ }.freeze
892
+
893
+ # Rails assigns an enum its stored integer too (`status: 1` from a JSON
894
+ # client), but a :string field passes that on as "1" — which in: rejects,
895
+ # and which Rails' enum rejects as well, so admitting it also means mapping
896
+ # it back to its key. Left as a TODO rather than drafted: forms, the
897
+ # common client, send the key. Only for an enum that stores Integers — a
898
+ # string-backed one has no second spelling to admit.
899
+ def enum_todo(column)
900
+ "TODO: Rails also assigns the stored integers (#{column.name}: 1) — if API clients send them, add " \
901
+ "#{column.enum}.values.map(&:to_s) to in: and map them back to keys with transform:"
902
+ end
903
+
904
+ def unread_todo(column)
905
+ "TODO: could not read what the model adds to #{column.name} (#{column.unread}) — check its enum and " \
906
+ "default by hand"
205
907
  end
206
908
 
207
909
  # NOT NULL without a database default is the only case a client truly
@@ -213,37 +915,106 @@ module Permittable
213
915
  return false if column.null
214
916
  return false unless column.default.nil?
215
917
 
216
- column.respond_to?(:default_function) && column.default_function ? false : true
918
+ !column.default_function
217
919
  end
218
920
 
921
+ # Scanned names are emitted with Symbol#inspect, not as `:#{name}`: a
922
+ # string-keyed `permit("2fa")` scans to a name that is not a bare symbol
923
+ # literal, and `:2fa` would make the whole draft a SyntaxError.
219
924
  def scanned_lines(scan, columns)
220
925
  lines = scan.scalars.map { |name| scanned_scalar_line(name, columns) }
221
926
  lines += scan.arrays.map do |name|
222
- "array :#{name}, of: :string # TODO: confirm the element type, and declare length: — an array without one is unbounded"
927
+ "array #{name.to_sym.inspect}, of: :string " \
928
+ "# TODO: confirm the element type, and declare length: — an array without one is unbounded"
223
929
  end
224
930
  scan.nested.each { |name, keys| lines += nested_lines(name, keys) }
225
- lines + scan.unparsed.map { |arg| "# TODO: could not parse from the permit call: #{arg}" }
931
+ scan.nested_arrays.each { |name, keys| lines += nested_array_lines(name, keys) }
932
+ lines + scan_todo_lines(scan)
933
+ end
934
+
935
+ # Each TODO says why the scan did not draft it: a parsed argument that
936
+ # belongs outside this contract is not one the parser failed to read.
937
+ def scan_todo_lines(scan)
938
+ # Array()/to_h: a Scan built by hand before these members existed leaves them nil.
939
+ Array(scan.conflicts).map { |conflict| "# TODO: #{conflict}" } +
940
+ scan.unparsed.map { |arg| "# TODO: could not parse from the permit call: #{arg}" } +
941
+ scan.other_envelopes.to_h.flat_map do |root, spellings|
942
+ spellings.map { |spelling| "# TODO: belongs to another envelope (#{root}): #{spelling}" }
943
+ end +
944
+ Array(scan.route_params).map { |arg| route_param_todo(scan, arg) } +
945
+ Array(scan.rootless).map { |arg| "# TODO: outside the #{scan.root} envelope, so not in this contract: #{arg}" }
946
+ end
947
+
948
+ # Only a bare key is a route or query param; an array or hash an expect
949
+ # call spells beside its envelope is body input this root cannot reach.
950
+ # A rootless call's keys (`rootless`) get the neutral wording whatever
951
+ # their shape: beside an envelope they may be a filter or a whole body.
952
+ def route_param_todo(scan, arg)
953
+ return "# TODO: route or query param, not a body field: #{arg}" if scalar_key(arg)
954
+ return "# TODO: outside the #{scan.root} envelope, so not in this contract: #{arg}" if scan.root
955
+
956
+ "# TODO: sent beside another envelope, so not in this contract: #{arg}"
226
957
  end
227
958
 
228
959
  def scanned_scalar_line(name, columns)
229
960
  column = columns && columns[name.to_s]
230
961
  return column_line(column) if column
231
- return "optional :#{name}, :string, virtual: true # TODO: not a database column — confirm the type" if columns
232
962
 
233
- "optional :#{name}, :string # TODO: confirm the type"
963
+ field = "optional #{name.to_sym.inspect}, :string"
964
+ return "#{field}, virtual: true # TODO: not a database column — confirm the type" if columns
965
+
966
+ "#{field} # TODO: confirm the type"
234
967
  end
235
968
 
236
969
  def nested_lines(name, keys)
237
- ["optional :#{name} do # TODO: drafted from `#{name}: [...]` — if this is an array of hashes, use `array :#{name} do`"] +
238
- keys.map { |key| " optional :#{key}, :string # TODO: confirm the type" } +
970
+ ["optional #{name.to_sym.inspect} do # TODO: drafted from `#{name}: [...]` — " \
971
+ "if this is an array of hashes, use `array #{name.to_sym.inspect} do`"] +
972
+ sub_field_lines(keys) +
239
973
  ["end"]
240
974
  end
241
975
 
976
+ # No TODO on the kind here, unlike nested_lines: `params.expect` spells an
977
+ # array of hashes `#{name}: [[...]]`, which says definitively what the
978
+ # equivalent permit call (`#{name}: [...]`) leaves ambiguous.
979
+ def nested_array_lines(name, keys)
980
+ ["array #{name.to_sym.inspect} do"] + sub_field_lines(keys) + ["end"]
981
+ end
982
+
983
+ def sub_field_lines(keys)
984
+ keys.map { |key| " optional #{key.to_sym.inspect}, :string # TODO: confirm the type" }
985
+ end
986
+
242
987
  HEADER = "# Drafted by permittable:generate — review the TODOs, then deploy: monitor\n" \
243
988
  "# mode reports violations (instrumentation + log) without rejecting requests.\n".freeze
244
989
 
245
- def render(signature, body)
246
- "#{HEADER}#{signature}\n#{body.map { |line| " #{line}\n" }.join}end\n"
990
+ UPDATE_NOTE = "# :update has nothing required — a PATCH sends only the fields it changes.\n".freeze
991
+
992
+ # One rule, unless a column made something `required`: that is true of a
993
+ # create, but an update carrying only the edited field would be rejected
994
+ # for everything it left out. So the update gets its own rule — the same
995
+ # fields, every one optional.
996
+ #
997
+ # `lines` drafts the body for one rule (see DraftColumn#in_rule), so
998
+ # each rule is drafted rather than edited from another's text. The
999
+ # single-rule body and the :update body differ exactly when some column
1000
+ # was drafted `required`, which is the test for splitting.
1001
+ #
1002
+ # Every drafted line is a declaration or a `# TODO` comment. A body of
1003
+ # comments only — a model whose sole columns are `type` and
1004
+ # `lock_version`, or have no contract type — would be a rule declaring
1005
+ # no field, which raises `a contract must declare at least one field`
1006
+ # when pasted. There is then nothing loadable to draft: nil, as for no
1007
+ # knowledge at all, which the rake task already skips.
1008
+ def render(root:, model:, &lines)
1009
+ shared = lines.call(:shared)
1010
+ return nil if shared.all? { |line| line.start_with?("#") }
1011
+
1012
+ update = lines.call(:update)
1013
+ rules = shared == update ? [[DEFAULT_ACTIONS, shared]] : [[%i[create], lines.call(:create)], [%i[update], update]]
1014
+ bodies = rules.map do |actions, body|
1015
+ "#{signature(root: root, model: model, actions: actions)}\n#{body.map { |line| " #{line}\n" }.join}end\n"
1016
+ end
1017
+ HEADER + bodies.join("\n#{UPDATE_NOTE}")
247
1018
  end
248
1019
  end
249
1020
  end