permittable 0.7.0 → 0.9.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.
@@ -1,8 +1,11 @@
1
+ require "ripper"
2
+
1
3
  module Permittable
2
4
  # Drafts a permit_params contract from what the app already knows: the
3
5
  # model's columns (types, NOT NULL, database defaults) and, when the
4
- # controller source is available, the strong-parameters calls already in it
5
- # (`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
6
9
  # STARTING POINT, not an oracle — everything the generator cannot know for
7
10
  # sure is marked with a TODO comment instead of guessed, and the whole
8
11
  # contract is emitted in monitor mode so pasting it changes nothing until
@@ -19,6 +22,27 @@ module Permittable
19
22
  DEFAULT_ACTIONS = %i[create update].freeze
20
23
  SKIPPED_COLUMNS = %w[created_at updated_at].freeze
21
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
+
22
46
  # Column type => contract type. Document-shaped columns map onto the
23
47
  # opaque `:json` field — the shape stays undeclared, which is what a
24
48
  # jsonb column is for, and `max_depth:`/`length:` can bound it later.
@@ -32,14 +56,34 @@ module Permittable
32
56
  json: :json, jsonb: :json, hstore: :json
33
57
  }.freeze
34
58
 
35
- # What a source scan recovered from existing permit calls. `scalars` are
36
- # plain `:key` arguments, `arrays` are `key: []`, `nested` maps `key:
37
- # [:a, :b]` onto its sub-keys, and `unparsed` keeps verbatim anything the
38
- # conservative parser would otherwise have silently dropped.
39
- 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
40
77
  def found?
41
78
  calls.positive?
42
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
43
87
  end
44
88
 
45
89
  # One permit call, with an optional leading `.require(:root)`. The args
@@ -48,44 +92,293 @@ module Permittable
48
92
  # half-read.
49
93
  PERMIT_CALL = /params\s*(?:\.\s*require\(\s*:(\w+)\s*\))?\s*\.\s*permit\(([^()]*)\)/m
50
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
+
51
126
  # A permit key: `:name`, `"name"`, or `'name'` (quotes must match —
52
127
  # anything else stays unparsed rather than guessed).
53
128
  SCALAR_KEY = /\A(?::(\w+)|"(\w+)"|'(\w+)')\z/
54
129
  ARRAY_ARG = /\A(\w+):\s*\[\s*\]\z/m
55
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
148
+
149
+ # Comment tokens. Ripper (stdlib) is used rather than a regexp because `#`
150
+ # is only a comment sometimes — it also appears inside string literals and
151
+ # `#{}` interpolation, and a permit call inside interpolation IS live code.
152
+ # String CONTENT is deliberately kept: `permit("name")` is a supported
153
+ # spelling, and its keys live in string tokens.
154
+ COMMENT_TOKENS = %i[on_comment on_embdoc on_embdoc_beg on_embdoc_end].freeze
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
56
165
 
57
166
  module_function
58
167
 
59
- # Merge every permit call found in `source` into one Scan. The first
60
- # `.require(:root)` seen wins, matching how a controller normally sticks
61
- # to one envelope across actions.
62
- def scan(source)
63
- result = Scan.new(root: nil, scalars: [], arrays: [], nested: {}, unparsed: [], calls: 0)
64
- (source || "").scan(PERMIT_CALL) do |root, args|
65
- result.calls += 1
66
- result.root ||= root&.to_sym
67
- split_args(args).each { |arg| classify_arg(result, arg) }
68
- end
168
+ # `source` with its comments removed. A controller keeping a commented-out
169
+ # `params.require(:admin).permit(:superuser)` for reference had :admin
170
+ # drafted as its root and :superuser as a permitted field — a wrong
171
+ # suggestion, and a security-flavoured one, from a line that does not run.
172
+ #
173
+ # Anything Ripper cannot lex falls back to the source unchanged, so a
174
+ # syntactically odd file scans exactly as it did before rather than not at
175
+ # all.
176
+ def executable_source(source)
177
+ tokens = code_tokens(source)
178
+ return source unless tokens
179
+
180
+ tokens.map { |token| token[2] }.join
181
+ rescue StandardError
182
+ source
183
+ end
184
+
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)
69
272
  result
70
273
  end
71
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
+
72
290
  # Draft a contract for one controller: model inferred from
73
291
  # controller_name (or passed explicitly), permit calls scanned from
74
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.
75
300
  def for_controller(controller, source: nil, model: nil)
76
- 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
77
310
  end
78
311
 
79
312
  # The core: knowledge in (columns and/or a scan), snippet out. Returns a
80
- # 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.
81
317
  def draft(model: nil, scan: nil)
82
318
  columns = columns_for(model)
83
319
  scan = nil unless scan&.found?
84
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
328
+
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
85
335
 
86
- root = scan ? scan.root : default_root(model)
87
- body = scan ? scanned_lines(scan, columns) : column_lines(columns.values)
88
- render(signature(root: root, model: columns && model), body)
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 }
89
382
  end
90
383
 
91
384
  def infer_model(controller)
@@ -99,17 +392,156 @@ module Permittable
99
392
  # is no model or its schema is unreachable (same philosophy as the drift
100
393
  # guard: never let generation crash on a half-migrated database).
101
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
+ def schema_columns(model)
102
404
  return nil unless model.respond_to?(:columns)
103
405
  return nil unless model.table_exists?
104
406
 
105
407
  # Array() flattens a composite primary key (an Array in Rails 7.1+)
106
408
  # into its column names; a nil primary key becomes [].
107
409
  skipped = SKIPPED_COLUMNS + Array(model.primary_key).map(&:to_s)
108
- model.columns.reject { |c| skipped.include?(c.name) }.to_h { |c| [c.name, c] }
410
+ model.columns.reject { |c| skipped.include?(c.name) }
109
411
  rescue StandardError
110
412
  nil
111
413
  end
112
414
 
415
+ # The sensitive role is read OUTSIDE model_facts' rescue: it only
416
+ # compares the column's name with the model's STI and locking settings,
417
+ # and a failed enum or default read must not also make `type` or
418
+ # `lock_version` client-writable.
419
+ def draft_column(model, column)
420
+ DraftColumn.new(name: column.name, type: column.type, null: column.null,
421
+ default_function: column.respond_to?(:default_function) && column.default_function,
422
+ sensitive: sensitive_role(model, column.name), **model_facts(model, column))
423
+ end
424
+
425
+ # What the model adds to one column. An error reading it degrades THAT
426
+ # column to its database facts, with the error in a TODO — rather than
427
+ # raising, which would abort permittable:generate for every controller
428
+ # over one odd model, or dropping the column, which would hide it. The
429
+ # message is squished because it lands in a one-line comment, where a
430
+ # newline would end the comment and leave the draft unparseable.
431
+ def model_facts(model, column)
432
+ enum = enum_for(model, column.name)
433
+ { enum: enum && enum[:accessor], enum_integers: enum && enum[:mapping].values.all?(Integer),
434
+ default: default_note(model, column, enum) }
435
+ rescue StandardError => e
436
+ { default: database_default_note(column, nil), unread: "#{e.class}: #{e.message}".squish }
437
+ end
438
+
439
+ # The default Rails actually applies, as a comment. One the MODEL
440
+ # declares — `attribute :carrier, default: "post"`, `enum ..., default:
441
+ # :pending` — never reaches the schema, yet it fills the field on create
442
+ # just as a database default does, so it too keeps a NOT NULL column
443
+ # from being `required`. It wins over the database's, as it does in
444
+ # Rails.
445
+ def default_note(model, column, enum)
446
+ declared = model_default(model, column.name, enum)
447
+ declared ? "model default: #{declared}" : database_default_note(column, enum)
448
+ end
449
+
450
+ # An enum's database default is its stored integer; shown as its key
451
+ # (`"pending"`, not `0`), which is what the drafted field accepts.
452
+ def database_default_note(column, enum)
453
+ default = column.default
454
+ return nil if default.nil?
455
+
456
+ default = enum[:mapping].find { |_key, value| value.to_s == default.to_s }&.first || default if enum
457
+ "database default: #{default.inspect}"
458
+ end
459
+
460
+ # `_default_attributes` (nodoc, but what `column_defaults` is built from
461
+ # in every supported Rails, 6.1–8.1) holds a UserProvidedDefault for each
462
+ # default the model declares; the rest came from the database. Read per
463
+ # attribute rather than through column_defaults, which evaluates every
464
+ # Proc default at once.
465
+ #
466
+ # The note is written from the default AS DECLARED (its private
467
+ # `user_provided_value`, the same in 6.1–8.1), never from `value`:
468
+ # casting runs the attribute type's code, which is the app's, and can
469
+ # raise — `attribute :prefs, :json, default: {}` does. A Proc is not
470
+ # called either: drafting must not run app code with side effects, and
471
+ # today's value says nothing about tomorrow's. An enum default declared
472
+ # by its stored value is shown as its key, like a database one.
473
+ def model_default(model, name, enum)
474
+ return nil unless model.respond_to?(:_default_attributes) && defined?(ActiveModel::Attribute::UserProvidedDefault)
475
+
476
+ attribute = model._default_attributes[name]
477
+ return nil unless attribute.is_a?(ActiveModel::Attribute::UserProvidedDefault)
478
+
479
+ declared = attribute.send(:user_provided_value)
480
+ return "computed by a Proc" if declared.is_a?(Proc)
481
+ return nil if declared.nil?
482
+
483
+ declared = enum[:mapping].key(declared) || declared if enum
484
+ default_literal(declared)
485
+ end
486
+
487
+ # How a declared default reads in a comment: strings and Symbols (an
488
+ # enum key given as `:pending`) quoted, numbers and times as people
489
+ # write them — `1.5` rather than BigDecimal's `0.15e1`. Anything else is
490
+ # inspected and squished, since the note must stay on one line.
491
+ def default_literal(value)
492
+ case value
493
+ when String, Symbol then value.to_s.inspect
494
+ when BigDecimal then value.to_s("F")
495
+ when Numeric then value.to_s
496
+ else value.respond_to?(:strftime) ? value.to_s : value.inspect.squish
497
+ end
498
+ end
499
+
500
+ # A Rails enum stores an integer but is ASSIGNED its key: a form sends
501
+ # "shipped", never 1, so drafting the column type (:integer) would reject
502
+ # every legitimate request. The draft references the model's own
503
+ # accessor rather than inlining today's keys, so adding a value to the
504
+ # enum cannot leave the contract rejecting it.
505
+ #
506
+ # Rails defines the accessor for any enum name, but only an identifier
507
+ # can be CALLED as `Model.name`: a `first-status` enum's
508
+ # `Order.first-statuses.keys` parses as `Order.first - statuses.keys`,
509
+ # which runs a query when the draft loads. defined_enums reaches the
510
+ # same mapping by name.
511
+ def enum_for(model, name)
512
+ return nil unless model.respond_to?(:defined_enums)
513
+
514
+ mapping = model.defined_enums[name]
515
+ return nil unless mapping
516
+
517
+ plural = name.pluralize
518
+ accessor = METHOD_NAME.match?(plural) ? "#{model.name}.#{plural}" : "#{model.name}.defined_enums[#{name.inspect}]"
519
+ { mapping: mapping, accessor: accessor }
520
+ end
521
+
522
+ METHOD_NAME = /\A[a-z_][a-zA-Z0-9_]*\z/
523
+
524
+ # The role that makes a column dangerous for a client to write — :sti,
525
+ # :locking, or nil. What the draft then does with it is column_line's
526
+ # call, since that depends on whether the permit call lists it.
527
+ def sensitive_role(model, name)
528
+ # Only a model that actually uses STI: the inheritance column is set
529
+ # (not nil) AND exists. Mass-assigning it changes which class the
530
+ # record is loaded as — `type: "Admin"` on a signup form.
531
+ return :sti if model.respond_to?(:inheritance_column) && model.inheritance_column.to_s == name
532
+
533
+ :locking if locking_column?(model, name)
534
+ end
535
+
536
+ # Mirrors the STI check's respect for `inheritance_column = nil`: with
537
+ # `self.lock_optimistically = false` Rails never reads or bumps the
538
+ # column, so it is an ordinary integer and drafts as one.
539
+ def locking_column?(model, name)
540
+ return false unless model.respond_to?(:locking_column) && model.locking_column.to_s == name
541
+
542
+ !model.respond_to?(:lock_optimistically) || model.lock_optimistically
543
+ end
544
+
113
545
  # -- scan parsing -------------------------------------------------------
114
546
 
115
547
  # Split a permit argument list on top-level commas only, so `address:
@@ -127,23 +559,231 @@ module Permittable
127
559
  parts.map(&:strip).reject(&:empty?)
128
560
  end
129
561
 
562
+ # An expect call's arguments, as one Call per envelope. The bracketed
563
+ # arguments are required root envelopes and their contents are fields.
564
+ # Plain symbols are fields only when there is no envelope
565
+ # (`params.expect(:q, :page)` is a rootless filter); alongside one they
566
+ # are route params rather than body fields, so they stay visible instead
567
+ # of being drafted as contract fields. Each envelope is its own candidate
568
+ # root: `expect(post: [...], comment: [...])` can be where the comment
569
+ # contract's fields live, and reading only the first envelope hid them.
570
+ def expect_calls(position, args)
571
+ envelopes, others = args.partition { |arg| envelope(arg) }
572
+ return [expect_rootless_call(position, others)] if envelopes.empty?
573
+
574
+ envelopes.each_with_index.map do |arg, index|
575
+ root, fields = envelope(arg)
576
+ Call.new(position, root, fields, index.zero? ? others : [], unparsed_arg(arg))
577
+ end
578
+ end
579
+
580
+ # An expect argument as `[root, fields]` when it is an envelope, else nil.
581
+ # A constant envelope's one "field" is the constant, kept as unparsable.
582
+ def envelope(arg)
583
+ if (match = EXPECT_ENVELOPE.match(arg))
584
+ [match[1].to_sym, split_args(match[2])]
585
+ elsif (match = EXPECT_CONSTANT_ENVELOPE.match(arg))
586
+ [match[1].to_sym, [match[2]]]
587
+ end
588
+ end
589
+
590
+ # A rooted permit call is quoted as the call itself, on one line, so its
591
+ # TODO can be found in the source it came from. `source` is
592
+ # executable_source: `match` was found by scanning masked_source, whose
593
+ # own text may hold placeholders rather than a real string argument's
594
+ # characters (see masked_source), so every group is read back out of
595
+ # `source` by position instead of off `match` directly.
596
+ def permit_call(source, match)
597
+ args = split_args(group(source, match, 2))
598
+ root = group(source, match, 1)
599
+ return Call.new(match.begin(0), nil, args, []) unless root
600
+
601
+ spelling = unparsed_arg(group(source, match, 0)).gsub(/\(\s+/, "(").gsub(/\s+\)/, ")")
602
+ Call.new(match.begin(0), root.to_sym, args, [], spelling)
603
+ end
604
+
605
+ # A rootless expect call — or, when its one key is a route param
606
+ # (ROUTE_PARAM_KEY), a lookup with no fields at all, so it can neither
607
+ # score toward the root nor be drafted as a field when the rootless calls
608
+ # win.
609
+ def expect_rootless_call(position, args)
610
+ route = args.size == 1 && (key = scalar_key(args.first)) && ROUTE_PARAM_KEY.match?(key.to_s)
611
+ route ? Call.new(position, nil, [], args) : Call.new(position, nil, args, [])
612
+ end
613
+
614
+ # The model's own envelope wins outright when one of the calls uses it
615
+ # (`preferred`, derived like default_root): it is the envelope a Rails
616
+ # form for the model sends, and the only way to root a
617
+ # `require(:post).permit(*PERMITTED)` that has no field to score with.
618
+ #
619
+ # With no model to say which envelope is the form's, an envelope with a
620
+ # parsed field beats the rootless calls, as any envelope always did: an
621
+ # index action's `params.permit(:page, :per_page)` must not take the root
622
+ # from a one-field `post_params` on a guess. An envelope with NO parsed
623
+ # field — `expect(search: FILTERS)` — only beats rootless calls that have
624
+ # none either: beside `params.permit(:title, :body)` it would root a draft
625
+ # with nothing to declare, where master drafted title and body.
626
+ #
627
+ # Otherwise the candidate — an envelope, or, with a model that no
628
+ # envelope matches, the rootless calls together (root nil) — declaring
629
+ # the most distinct PARSED fields across its calls wins. Now that
630
+ # the losers become TODOs rather than fields, "first seen" alone let an
631
+ # index action's `require(:search).permit(:q)` win the root and push the
632
+ # real `expect(post: [...])` into a TODO; and considering envelopes only
633
+ # let that same search form beat a rootless `params.permit(:title, :body,
634
+ # :published)` carrying the whole create body. Unparsable arguments
635
+ # (`*PERMITTED`) count for nothing: they are not fields the draft can
636
+ # declare.
637
+ #
638
+ # A tie goes to an envelope, then to the first seen in the source. The
639
+ # envelope wins a tie because a rootless key beside one is usually a
640
+ # route or query param: a one-field Rails 8 scaffold calls
641
+ # `params.expect(:id)` in `set_post` above `params.expect(post: [:title])`.
642
+ def choose_root(calls, preferred = nil, exclude = [])
643
+ calls = calls.reject { |call| exclude.include?(call.root) }
644
+ return preferred if preferred && calls.any? { |call| call.root == preferred }
645
+
646
+ scores = calls.group_by(&:root).transform_values do |same|
647
+ same.flat_map { |call| call.fields.filter_map { |arg| parse_arg(arg)&.at(1) } }.uniq.size
648
+ end
649
+ scores = envelopes_first(scores) unless preferred
650
+ best = scores.each_with_index.max_by { |(root, score), index| [score, root ? 1 : 0, -index] }
651
+ best&.first&.first
652
+ end
653
+
654
+ # The model-less rule above: the rootless candidate is dropped when an
655
+ # envelope has a parsed field, or when it has none itself.
656
+ def envelopes_first(scores)
657
+ envelopes = scores.except(nil)
658
+ return scores if envelopes.empty?
659
+
660
+ envelopes.values.max.positive? || scores.fetch(nil, 0).zero? ? envelopes : scores
661
+ end
662
+
663
+ # Fold one call into the scan: its fields when it shares the scan's root
664
+ # (including both having none), a TODO otherwise — its arguments filed
665
+ # under its own root, or as rootless keys, so the TODO says where they
666
+ # belong rather than that they could not be read.
667
+ def merge_call(result, call)
668
+ if call.root == result.root
669
+ call.fields.each { |arg| classify_arg(result, arg) }
670
+ elsif call.root
671
+ result.other_envelopes[call.root] = (result.other_envelopes[call.root] || []) | [call.spelling]
672
+ else
673
+ result.rootless |= call.fields.map { |arg| unparsed_arg(arg) }
674
+ end
675
+ result.route_params |= call.route_params.map { |arg| unparsed_arg(arg) }
676
+ end
677
+
678
+ # A key the winning root drafts as a field is not also a TODO: beside
679
+ # `expect(post: [:title, :group_id])`, a `Group.find(params.expect(:group_id))`
680
+ # lookup or a rootless `params.permit(:group_id)` would otherwise say
681
+ # "not a body field" about a field the draft declares. Only a bare key is
682
+ # dropped: a shaped copy (`tags: [:z]` beside the envelope's `tags: [:a]`)
683
+ # may carry sub-keys the drafted field lacks, and dropping its TODO would
684
+ # lose them.
685
+ def drop_drafted_todos(result)
686
+ drafted = SHAPES.keys.flat_map { |shape| shape_keys(result, shape) }
687
+ %i[route_params rootless].each do |member|
688
+ result[member] = result[member].reject { |arg| drafted.include?(scalar_key(arg)) }
689
+ end
690
+ end
691
+
692
+ # A key permitted in two shapes across actions is declared once — a
693
+ # contract rejects a field declared twice — and how depends on whether
694
+ # one shape accepts what the other is sent:
695
+ #
696
+ # - a nested hash and an array of hashes merge into the array of hashes,
697
+ # with the sub-keys of both: `key: [[:a]]` is expect's definitive
698
+ # spelling of the array of hashes that `key: [:a]` leaves ambiguous,
699
+ # and dropping the loser's sub-keys would reject the ones its action
700
+ # sends;
701
+ # - an array of scalars and either hash shape accept disjoint input, so
702
+ # neither is drafted — picking one would reject what the other action
703
+ # sends — and the TODO names both;
704
+ # - anything else goes to the richer shape (SHAPES is ordered richest
705
+ # first), the one at least one action demonstrably accepts: a scalar
706
+ # declaration would reject the hash or array that action is sent.
707
+ def resolve_conflicts(result)
708
+ SHAPES.keys.flat_map { |shape| shape_keys(result, shape) }.uniq.each do |key|
709
+ shapes = SHAPES.keys.select { |shape| shape_keys(result, shape).include?(key) }
710
+ next if shapes.size < 2
711
+
712
+ result.conflicts << resolve_conflict(result, key, shapes)
713
+ end
714
+ end
715
+
716
+ def resolve_conflict(result, key, shapes)
717
+ merged = (HASH_SHAPES - shapes).empty?
718
+ result.nested_arrays[key] |= result.nested[key] if merged
719
+ if shapes.include?(:arrays) && shapes.intersect?(HASH_SHAPES)
720
+ shapes.each { |shape| result[shape].delete(key) }
721
+ result.undecided << key
722
+ return "#{key} is permitted as #{listed_shapes(shapes)}, which accept different input — " \
723
+ "drafted as #{shapes.size == 2 ? 'neither' : 'none of them'}; declare the shape its actions share"
724
+ end
725
+
726
+ shapes.drop(1).each { |shape| result[shape].delete(key) }
727
+ "#{key} is permitted as #{listed_shapes(shapes)} — drafted as #{the_shape(shapes.first)}" \
728
+ "#{merge_note(shapes) if merged}"
729
+ end
730
+
731
+ # A merge keeps the nested hash's sub-keys, so only a scalar is dropped.
732
+ def merge_note(shapes)
733
+ " with the nested hash's sub-keys merged in#{', dropping the scalar' if shapes.include?(:scalars)}"
734
+ end
735
+
736
+ def the_shape(shape)
737
+ SHAPES[shape].sub(/\Aan? /, "the ")
738
+ end
739
+
740
+ def shape_keys(result, shape)
741
+ keys = result[shape]
742
+ keys.is_a?(Hash) ? keys.keys : keys
743
+ end
744
+
745
+ # "both a scalar and an array", "a scalar, a nested hash and an array of
746
+ # hashes" — poorest first.
747
+ def listed_shapes(shapes)
748
+ names = shapes.reverse.map { |shape| SHAPES[shape] }
749
+ names.size == 2 ? "both #{names.join(' and ')}" : "#{names[0..-2].join(', ')} and #{names.last}"
750
+ end
751
+
130
752
  def classify_arg(result, arg)
753
+ shape, key, sub_keys = parse_arg(arg)
754
+ if shape.nil?
755
+ result.unparsed |= [unparsed_arg(arg)]
756
+ elsif sub_keys
757
+ result[shape][key] = (result[shape][key] || []) | sub_keys
758
+ else
759
+ result[shape] |= [key]
760
+ end
761
+ end
762
+
763
+ # One permit argument as `[shape, key]`, or `[shape, key, sub_keys]` for
764
+ # the two hash shapes — nil when the conservative parser cannot read it,
765
+ # including a nested list with any sub-key it cannot read.
766
+ def parse_arg(arg)
131
767
  if (key = scalar_key(arg))
132
- result.scalars |= [key]
768
+ [:scalars, key]
133
769
  elsif (match = ARRAY_ARG.match(arg))
134
- result.arrays |= [match[1].to_sym]
770
+ [:arrays, match[1].to_sym]
771
+ elsif (match = NESTED_ARRAY_ARG.match(arg))
772
+ nested_arg(:nested_arrays, match)
135
773
  elsif (match = NESTED_ARG.match(arg))
136
- classify_nested(result, match, arg)
137
- else
138
- result.unparsed |= [arg.gsub(/\s+/, " ")]
774
+ nested_arg(:nested, match)
139
775
  end
140
776
  end
141
777
 
142
- def classify_nested(result, match, arg)
143
- keys = split_args(match[2]).map { |part| scalar_key(part) }
144
- return result.unparsed |= [arg.gsub(/\s+/, " ")] if keys.any?(&:nil?)
778
+ # An empty list (`meta: [[ ]]`, `meta: [ , ]`) is unparsable too: it
779
+ # would draft a `do end` block, which the DSL rejects.
780
+ def nested_arg(shape, match)
781
+ sub_keys = split_args(match[2]).map { |part| scalar_key(part) }
782
+ [shape, match[1].to_sym, sub_keys] unless sub_keys.empty? || sub_keys.any?(&:nil?)
783
+ end
145
784
 
146
- result.nested[match[1].to_sym] = (result.nested[match[1].to_sym] || []) | keys
785
+ def unparsed_arg(arg)
786
+ arg.gsub(/\s+/, " ")
147
787
  end
148
788
 
149
789
  def scalar_key(part)
@@ -153,12 +793,17 @@ module Permittable
153
793
 
154
794
  # -- drafting -----------------------------------------------------------
155
795
 
796
+ # model_name.param_key is the key Rails form helpers submit under and
797
+ # `params.require` reads — `blog_post` for Blog::Post, where demodulizing
798
+ # the class name gave `post` and an enforced draft 400'd every submit.
156
799
  def default_root(model)
800
+ return model.model_name.param_key.to_sym if model.respond_to?(:model_name)
801
+
157
802
  model.name.demodulize.underscore.to_sym
158
803
  end
159
804
 
160
- def signature(root:, model:)
161
- parts = ["permit_params #{DEFAULT_ACTIONS.map(&:inspect).join(', ')}"]
805
+ def signature(root:, model:, actions: DEFAULT_ACTIONS)
806
+ parts = ["permit_params #{actions.map(&:inspect).join(', ')}"]
162
807
  parts << "root: :#{root}" if root
163
808
  parts << "model: #{model.name}" if model
164
809
  parts << "mode: :monitor do"
@@ -169,13 +814,77 @@ module Permittable
169
814
  columns.map { |column| column_line(column) }
170
815
  end
171
816
 
817
+ # One column's line in one rule — `column.rule` is :shared (a single
818
+ # :create, :update rule), :create, or :update, which never requires
819
+ # anything (see render). `column.listed` says the controller's own
820
+ # permit call lists the column, which decides what a sensitive column
821
+ # becomes.
822
+ #
823
+ # Names are emitted with Symbol#inspect, so a column called `first-name`
824
+ # or `2fa_enabled` drafts as `:"first-name"` rather than as Ruby that
825
+ # does not parse.
172
826
  def column_line(column)
173
- type = COLUMN_TYPES[column.type]
827
+ return "# TODO: #{column.name} #{omitted_note(column)}" if column.sensitive && !column.listed
828
+
829
+ type = column.enum ? :string : COLUMN_TYPES[column.type]
174
830
  return "# TODO: #{column.name} (#{column.type}) has no contract type — declare it as a nested block or an array" unless type
175
831
 
176
- line = "#{required_column?(column) ? 'required' : 'optional'} :#{column.name}, :#{type}"
177
- line += " # database default: #{column.default.inspect}" unless column.default.nil?
178
- line
832
+ required = column.rule != :update && required_column?(column)
833
+ line = "#{required ? 'required' : 'optional'} #{column.name.to_sym.inspect}, :#{type}"
834
+ line += ", in: #{column.enum}.keys" if column.enum
835
+ notes = [column.default, *column_todos(column)].compact
836
+ notes.empty? ? line : "#{line} # #{notes.join('; ')}"
837
+ end
838
+
839
+ # Drafting from columns alone, a sensitive column is left out of the
840
+ # fields — nothing says any client sends it — and named here instead.
841
+ # The lock_version note says where to declare it in the rules actually
842
+ # drafted: stale-update detection is an update's concern, so a split
843
+ # :create rule points at the :update rule rather than at itself.
844
+ def omitted_note(column)
845
+ return STI_OMITTED if column.sensitive == :sti
846
+
847
+ where = column.rule == :create ? "in the :update rule below" : "here"
848
+ "is the optimistic-locking column — Rails increments it on every save; if your edit forms round-trip it " \
849
+ "as a hidden field for stale-update detection, declare `optional #{column.name.to_sym.inspect}, :integer` #{where}"
850
+ end
851
+
852
+ STI_OMITTED = "is the STI inheritance column — assigning it changes the record's class, so no client should " \
853
+ "send it; if clients really pick the subclass, declare it with in: the allowed class names".freeze
854
+
855
+ # The TODOs a drafted column line carries. A sensitive column the permit
856
+ # call lists stays a field — omitting lock_version there would silently
857
+ # switch off the stale-update detection the app wired up, the moment the
858
+ # draft is enforced — but says why it deserves a second look.
859
+ def column_todos(column)
860
+ todos = []
861
+ todos << "TODO: #{column.name} #{SCANNED_SENSITIVE.fetch(column.sensitive)}" if column.sensitive
862
+ todos << enum_todo(column) if column.enum_integers
863
+ todos << unread_todo(column) if column.unread
864
+ todos
865
+ end
866
+
867
+ SCANNED_SENSITIVE = {
868
+ locking: "is the optimistic-locking column — kept because the permit call lists it: an edit form that " \
869
+ "round-trips it is how Rails detects a stale update; never give it a default:",
870
+ sti: "is the STI inheritance column — kept because the permit call lists it, but assigning it changes the " \
871
+ "record's class: restrict it with in: the subclass names a client may pick"
872
+ }.freeze
873
+
874
+ # Rails assigns an enum its stored integer too (`status: 1` from a JSON
875
+ # client), but a :string field passes that on as "1" — which in: rejects,
876
+ # and which Rails' enum rejects as well, so admitting it also means mapping
877
+ # it back to its key. Left as a TODO rather than drafted: forms, the
878
+ # common client, send the key. Only for an enum that stores Integers — a
879
+ # string-backed one has no second spelling to admit.
880
+ def enum_todo(column)
881
+ "TODO: Rails also assigns the stored integers (#{column.name}: 1) — if API clients send them, add " \
882
+ "#{column.enum}.values.map(&:to_s) to in: and map them back to keys with transform:"
883
+ end
884
+
885
+ def unread_todo(column)
886
+ "TODO: could not read what the model adds to #{column.name} (#{column.unread}) — check its enum and " \
887
+ "default by hand"
179
888
  end
180
889
 
181
890
  # NOT NULL without a database default is the only case a client truly
@@ -187,35 +896,106 @@ module Permittable
187
896
  return false if column.null
188
897
  return false unless column.default.nil?
189
898
 
190
- column.respond_to?(:default_function) && column.default_function ? false : true
899
+ !column.default_function
191
900
  end
192
901
 
902
+ # Scanned names are emitted with Symbol#inspect, not as `:#{name}`: a
903
+ # string-keyed `permit("2fa")` scans to a name that is not a bare symbol
904
+ # literal, and `:2fa` would make the whole draft a SyntaxError.
193
905
  def scanned_lines(scan, columns)
194
906
  lines = scan.scalars.map { |name| scanned_scalar_line(name, columns) }
195
- lines += scan.arrays.map { |name| "array :#{name}, of: :string # TODO: confirm the element type" }
907
+ lines += scan.arrays.map do |name|
908
+ "array #{name.to_sym.inspect}, of: :string " \
909
+ "# TODO: confirm the element type, and declare length: — an array without one is unbounded"
910
+ end
196
911
  scan.nested.each { |name, keys| lines += nested_lines(name, keys) }
197
- lines + scan.unparsed.map { |arg| "# TODO: could not parse from the permit call: #{arg}" }
912
+ scan.nested_arrays.each { |name, keys| lines += nested_array_lines(name, keys) }
913
+ lines + scan_todo_lines(scan)
914
+ end
915
+
916
+ # Each TODO says why the scan did not draft it: a parsed argument that
917
+ # belongs outside this contract is not one the parser failed to read.
918
+ def scan_todo_lines(scan)
919
+ # Array()/to_h: a Scan built by hand before these members existed leaves them nil.
920
+ Array(scan.conflicts).map { |conflict| "# TODO: #{conflict}" } +
921
+ scan.unparsed.map { |arg| "# TODO: could not parse from the permit call: #{arg}" } +
922
+ scan.other_envelopes.to_h.flat_map do |root, spellings|
923
+ spellings.map { |spelling| "# TODO: belongs to another envelope (#{root}): #{spelling}" }
924
+ end +
925
+ Array(scan.route_params).map { |arg| route_param_todo(scan, arg) } +
926
+ Array(scan.rootless).map { |arg| "# TODO: outside the #{scan.root} envelope, so not in this contract: #{arg}" }
927
+ end
928
+
929
+ # Only a bare key is a route or query param; an array or hash an expect
930
+ # call spells beside its envelope is body input this root cannot reach.
931
+ # A rootless call's keys (`rootless`) get the neutral wording whatever
932
+ # their shape: beside an envelope they may be a filter or a whole body.
933
+ def route_param_todo(scan, arg)
934
+ return "# TODO: route or query param, not a body field: #{arg}" if scalar_key(arg)
935
+ return "# TODO: outside the #{scan.root} envelope, so not in this contract: #{arg}" if scan.root
936
+
937
+ "# TODO: sent beside another envelope, so not in this contract: #{arg}"
198
938
  end
199
939
 
200
940
  def scanned_scalar_line(name, columns)
201
941
  column = columns && columns[name.to_s]
202
942
  return column_line(column) if column
203
- return "optional :#{name}, :string, virtual: true # TODO: not a database column — confirm the type" if columns
204
943
 
205
- "optional :#{name}, :string # TODO: confirm the type"
944
+ field = "optional #{name.to_sym.inspect}, :string"
945
+ return "#{field}, virtual: true # TODO: not a database column — confirm the type" if columns
946
+
947
+ "#{field} # TODO: confirm the type"
206
948
  end
207
949
 
208
950
  def nested_lines(name, keys)
209
- ["optional :#{name} do # TODO: drafted from `#{name}: [...]` — if this is an array of hashes, use `array :#{name} do`"] +
210
- keys.map { |key| " optional :#{key}, :string # TODO: confirm the type" } +
951
+ ["optional #{name.to_sym.inspect} do # TODO: drafted from `#{name}: [...]` — " \
952
+ "if this is an array of hashes, use `array #{name.to_sym.inspect} do`"] +
953
+ sub_field_lines(keys) +
211
954
  ["end"]
212
955
  end
213
956
 
957
+ # No TODO on the kind here, unlike nested_lines: `params.expect` spells an
958
+ # array of hashes `#{name}: [[...]]`, which says definitively what the
959
+ # equivalent permit call (`#{name}: [...]`) leaves ambiguous.
960
+ def nested_array_lines(name, keys)
961
+ ["array #{name.to_sym.inspect} do"] + sub_field_lines(keys) + ["end"]
962
+ end
963
+
964
+ def sub_field_lines(keys)
965
+ keys.map { |key| " optional #{key.to_sym.inspect}, :string # TODO: confirm the type" }
966
+ end
967
+
214
968
  HEADER = "# Drafted by permittable:generate — review the TODOs, then deploy: monitor\n" \
215
969
  "# mode reports violations (instrumentation + log) without rejecting requests.\n".freeze
216
970
 
217
- def render(signature, body)
218
- "#{HEADER}#{signature}\n#{body.map { |line| " #{line}\n" }.join}end\n"
971
+ UPDATE_NOTE = "# :update has nothing required — a PATCH sends only the fields it changes.\n".freeze
972
+
973
+ # One rule, unless a column made something `required`: that is true of a
974
+ # create, but an update carrying only the edited field would be rejected
975
+ # for everything it left out. So the update gets its own rule — the same
976
+ # fields, every one optional.
977
+ #
978
+ # `lines` drafts the body for one rule (see DraftColumn#in_rule), so
979
+ # each rule is drafted rather than edited from another's text. The
980
+ # single-rule body and the :update body differ exactly when some column
981
+ # was drafted `required`, which is the test for splitting.
982
+ #
983
+ # Every drafted line is a declaration or a `# TODO` comment. A body of
984
+ # comments only — a model whose sole columns are `type` and
985
+ # `lock_version`, or have no contract type — would be a rule declaring
986
+ # no field, which raises `a contract must declare at least one field`
987
+ # when pasted. There is then nothing loadable to draft: nil, as for no
988
+ # knowledge at all, which the rake task already skips.
989
+ def render(root:, model:, &lines)
990
+ shared = lines.call(:shared)
991
+ return nil if shared.all? { |line| line.start_with?("#") }
992
+
993
+ update = lines.call(:update)
994
+ rules = shared == update ? [[DEFAULT_ACTIONS, shared]] : [[%i[create], lines.call(:create)], [%i[update], update]]
995
+ bodies = rules.map do |actions, body|
996
+ "#{signature(root: root, model: model, actions: actions)}\n#{body.map { |line| " #{line}\n" }.join}end\n"
997
+ end
998
+ HEADER + bodies.join("\n#{UPDATE_NOTE}")
219
999
  end
220
1000
  end
221
1001
  end