permittable 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -0
- data/README.md +368 -24
- data/lib/permittable/audit.rb +11 -5
- data/lib/permittable/authored_values.rb +70 -10
- data/lib/permittable/column_guard.rb +31 -11
- data/lib/permittable/contract.rb +50 -1
- data/lib/permittable/error_envelope.rb +60 -6
- data/lib/permittable/generator.rb +294 -61
- data/lib/permittable/json_schema.rb +102 -15
- data/lib/permittable/open_api.rb +41 -2
- data/lib/permittable/railtie.rb +87 -0
- data/lib/permittable/rspec.rb +49 -7
- data/lib/permittable/tasks/generate.rake +6 -4
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +209 -29
- metadata +2 -2
|
@@ -32,12 +32,18 @@ module Permittable
|
|
|
32
32
|
# Building these once in columns_for keeps every drafting path (columns
|
|
33
33
|
# alone, or a scan typed from columns) reading the same answers.
|
|
34
34
|
#
|
|
35
|
+
# `assigned` marks a column the database or Rails fills in (a generated
|
|
36
|
+
# primary key, a timestamp): drafted from the columns alone it is left
|
|
37
|
+
# out, and a permit call that lists it gets it typed from the schema, but
|
|
38
|
+
# never `required`, since no client has to send it. A primary key the
|
|
39
|
+
# client must send (a natural key) is not assigned (see assigned?).
|
|
40
|
+
#
|
|
35
41
|
# `rule` and `listed` are how column_line is told what it is drafting
|
|
36
42
|
# for: the rule (see render) and whether the controller's permit call
|
|
37
43
|
# lists the column. They ride on the column, set by in_rule, so the scan
|
|
38
44
|
# path hands them to column_line without its own methods changing.
|
|
39
45
|
DraftColumn = Struct.new(:name, :type, :null, :default, :default_function, :enum, :enum_integers, :sensitive,
|
|
40
|
-
:unread, :rule, :listed, keyword_init: true) do
|
|
46
|
+
:unread, :rule, :listed, :assigned, keyword_init: true) do
|
|
41
47
|
def in_rule(rule, listed: false)
|
|
42
48
|
self.class.new(**to_h, rule: rule, listed: listed)
|
|
43
49
|
end
|
|
@@ -86,11 +92,29 @@ module Permittable
|
|
|
86
92
|
end
|
|
87
93
|
end
|
|
88
94
|
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
|
|
95
|
+
# `params` itself, not the tail of another name: `user_params.permit(...)`
|
|
96
|
+
# is called on a helper's result (`params.require(:user)`, say), whose
|
|
97
|
+
# root a rootless read of it would drop. `@params` and `$params` are
|
|
98
|
+
# variables, except `@_params`, the one Rails itself keeps the controller's
|
|
99
|
+
# params in. A `.` before it is fine: `self.params.permit(...)` is the
|
|
100
|
+
# real thing.
|
|
101
|
+
PARAMS = /(?<![\w@$])(?:@_)?params/
|
|
102
|
+
|
|
103
|
+
# One permit call, with an optional leading `.require(:root)` — the root
|
|
104
|
+
# spelled `:user`, `"user"` or `'user'` (groups 1-3; a String root's
|
|
105
|
+
# content is masked, so it is read back by offset like any argument).
|
|
106
|
+
# The args capture (group 4) tolerates brackets and newlines but not
|
|
107
|
+
# parentheses — a call whose arguments contain a method call is skipped
|
|
108
|
+
# entirely rather than half-read. Without parentheses (`permit :name,
|
|
109
|
+
# :email`, common in older controllers) group 4 is empty and the
|
|
110
|
+
# arguments run to the end of the statement: see call_args.
|
|
111
|
+
PERMIT_CALL = /#{PARAMS}\s*(?:\.\s*require\(\s*(?::(\w+)|"([^"\n]*)"|'([^'\n]*)')\s*\))?\s*\.\s*
|
|
112
|
+
permit(?:\(([^()]*)\)|[ \t]+(?=[^\s(]))/mx
|
|
113
|
+
|
|
114
|
+
# A trailing `if`/`unless`/`rescue` modifier on a paren-less call, which
|
|
115
|
+
# is not one of its arguments.
|
|
116
|
+
# At the start too: `permit if admin?` has no arguments at all.
|
|
117
|
+
BARE_MODIFIER = /(?:\A|\s+)(?:if|unless|rescue)\s.*\z/m
|
|
94
118
|
|
|
95
119
|
# One Rails 8 `params.expect` call — the replacement for
|
|
96
120
|
# `require(...).permit(...)`, and the reason this scanner exists twice: a
|
|
@@ -100,21 +124,40 @@ module Permittable
|
|
|
100
124
|
# PERMIT_CALL: brackets and newlines are fine, a parenthesis means a
|
|
101
125
|
# method call in the arguments and the whole call is skipped rather than
|
|
102
126
|
# half-read.
|
|
103
|
-
EXPECT_CALL =
|
|
127
|
+
EXPECT_CALL = /#{PARAMS}\s*\.\s*expect(?:\(([^()]*)\)|[ \t]+(?=[^\s(]))/m
|
|
104
128
|
|
|
105
129
|
# The required root envelope of an expect call: `user: [...]`, where the
|
|
106
|
-
# brackets hold fields
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
EXPECT_ENVELOPE = /\A(\w+):\s*\[(
|
|
113
|
-
|
|
114
|
-
# An envelope whose fields live in a constant
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
|
|
130
|
+
# brackets hold fields. Which brackets hold fields is `envelope`'s call,
|
|
131
|
+
# not this pattern's: not the empty `tag_names: []` of an array-of-scalars
|
|
132
|
+
# root, and not the `comments: [[...]]` of an array-of-hashes root,
|
|
133
|
+
# neither of which a rooted contract can express. Rails reads `[[...]]` as
|
|
134
|
+
# an array of hashes only when the outer list holds that one element;
|
|
135
|
+
# `[[:name, :email], :age]` is the flat list `[:name, :email, :age]`.
|
|
136
|
+
EXPECT_ENVELOPE = /\A(\w+):\s*\[(.*)\]\z/m
|
|
137
|
+
|
|
138
|
+
# An envelope whose fields cannot be read: they live in a constant
|
|
139
|
+
# (`post: PERMITTED_PARAMS`) or a local (`post: permitted_keys`), or a
|
|
140
|
+
# word list holds a key that is not a plain one (`post: %i[first-name]`).
|
|
141
|
+
# Rails requires `post` whatever its value holds, so the root can still
|
|
142
|
+
# be read; read as a rootless argument it let a `params.permit(:page)`
|
|
143
|
+
# beside it take the root, and dropped the root when it stood alone. A
|
|
144
|
+
# value that opens with `[` is an array: EXPECT_ENVELOPE's fields, or a
|
|
145
|
+
# root a rooted contract cannot express (see there), never this.
|
|
146
|
+
EXPECT_OPAQUE_ENVELOPE = /\A(\w+):\s*([^\[\s].*)\z/m
|
|
147
|
+
|
|
148
|
+
# A %i/%w word list, or %I/%W: RuboCop's default Style/SymbolArray and
|
|
149
|
+
# Style/WordArray rewrite `[:name, :email]` into `%i[name email]`, and
|
|
150
|
+
# Rails reads the two alike. Delimited by `[]`, `{}`, `<>`, or the same
|
|
151
|
+
# punctuation at both ends — not `()`, as a parenthesis already makes the
|
|
152
|
+
# whole call unreadable (see PERMIT_CALL). The words are the text between
|
|
153
|
+
# the delimiters, whichever alternative matched.
|
|
154
|
+
WORD_LIST = /%[iIwW](?:\[[^\[\]]*\]|\{[^{}]*\}|<[^<>]*>|([^\w\s(),\[\]{}<>])(?:(?!\1).)*\1)/m
|
|
155
|
+
|
|
156
|
+
# A list among the filters, splatted or not: `[:name, :email]`, or a word
|
|
157
|
+
# list once bracket_word_lists has spelled it so. Rails flattens the
|
|
158
|
+
# filters it is given, so its elements are filters in their own right.
|
|
159
|
+
# Only a flat one: a list holding a list is left whole, unparsed.
|
|
160
|
+
LIST_ARG = /\A(?:\*\s*)?\[([^\[\]]*)\]\z/
|
|
118
161
|
|
|
119
162
|
# The key of a single-key rootless EXPECT lookup that is a route param,
|
|
120
163
|
# not input: the Rails 8 scaffold's `Post.find(params.expect(:id))`, or a
|
|
@@ -222,11 +265,19 @@ module Permittable
|
|
|
222
265
|
# The lexed tokens shared by executable_source and masked_source, with
|
|
223
266
|
# comments already dropped — nil when Ripper could not lex `source` at
|
|
224
267
|
# all, or found nothing.
|
|
268
|
+
# A comment token carries its line's newline ("# note\n"), so dropping
|
|
269
|
+
# it whole joined that line to the next. A parenthesised call never
|
|
270
|
+
# noticed; a paren-less one ends at its line (see bare_args_end), so the
|
|
271
|
+
# newline is kept.
|
|
225
272
|
def code_tokens(source)
|
|
226
273
|
tokens = Ripper.lex(source)
|
|
227
274
|
return nil if tokens.nil? || tokens.empty?
|
|
228
275
|
|
|
229
|
-
tokens.
|
|
276
|
+
tokens.filter_map do |token|
|
|
277
|
+
next token unless COMMENT_TOKENS.include?(token[1])
|
|
278
|
+
|
|
279
|
+
[token[0], :on_nl, "\n", token[3]] if token[2].end_with?("\n")
|
|
280
|
+
end
|
|
230
281
|
end
|
|
231
282
|
|
|
232
283
|
# Merge every `params.permit` and `params.expect` call found in the
|
|
@@ -261,8 +312,11 @@ module Permittable
|
|
|
261
312
|
raw = source.to_s
|
|
262
313
|
source = executable_source(raw)
|
|
263
314
|
masked = masked_source(raw)
|
|
264
|
-
permits = matches(masked, PERMIT_CALL).
|
|
265
|
-
expects = matches(masked, EXPECT_CALL).
|
|
315
|
+
permits = matches(masked, PERMIT_CALL).filter_map { |match| permit_call(source, masked, match) }
|
|
316
|
+
expects = matches(masked, EXPECT_CALL).filter_map do |match|
|
|
317
|
+
args, = call_args(source, masked, match, 1)
|
|
318
|
+
expect_calls(match.begin(0), split_args(args)) if args
|
|
319
|
+
end
|
|
266
320
|
result.calls = permits.size + expects.size
|
|
267
321
|
calls = (permits + expects).flatten.sort_by.with_index { |call, index| [call.position, index] }
|
|
268
322
|
result.root = choose_root(calls, model&.name && default_root(model), exclude)
|
|
@@ -321,18 +375,53 @@ module Permittable
|
|
|
321
375
|
# ActiveStorage's direct uploads controller is the case this exists for —
|
|
322
376
|
# it was drafted, and a contract pasted into a gem's controller is not
|
|
323
377
|
# one the app can keep. A "controller#action" entry takes only that
|
|
324
|
-
# action out of the draft, and
|
|
325
|
-
#
|
|
326
|
-
#
|
|
378
|
+
# action out of the draft, and so does an action the controller does not
|
|
379
|
+
# define (see undefined_actions): a SessionsController with only `create`
|
|
380
|
+
# and `destroy` gets no `:update` rule, and ApplicationController, which
|
|
381
|
+
# defines neither, would otherwise hand its rule to every controller
|
|
382
|
+
# below it. A controller with no drafted action left is skipped. Naming a
|
|
383
|
+
# controller to the task drafts it regardless (see named_target), the way
|
|
384
|
+
# it drafts a covered one.
|
|
327
385
|
def targets(controllers, ignore: Permittable.audit_ignore, root: Audit.app_root)
|
|
328
386
|
controllers.each_with_object({}) do |controller, found|
|
|
329
387
|
next if controller.respond_to?(:permittable_contracts) && controller.permittable_contracts.any?
|
|
330
388
|
|
|
331
|
-
|
|
389
|
+
undefined = undefined_actions(controller)
|
|
390
|
+
skipped = DEFAULT_ACTIONS.select do |action|
|
|
391
|
+
Audit.ignore_reason(controller, action, ignore: ignore, root: root) || undefined.include?(action)
|
|
392
|
+
end
|
|
332
393
|
found[controller] = skipped unless skipped == DEFAULT_ACTIONS
|
|
333
394
|
end
|
|
334
395
|
end
|
|
335
396
|
|
|
397
|
+
# What to leave out of the draft of a controller named to the task: the
|
|
398
|
+
# drafted actions it does not define, as in the sweep — unless it defines
|
|
399
|
+
# none of them. Then the name is all there is to go on, and it is drafted
|
|
400
|
+
# whole, as it always was.
|
|
401
|
+
def named_target(controller)
|
|
402
|
+
undefined = undefined_actions(controller)
|
|
403
|
+
undefined == DEFAULT_ACTIONS ? [] : undefined
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# The drafted actions `controller` does not define, by the audit's own
|
|
407
|
+
# reading of it (Audit.missing_lookup): not just its action_methods, since
|
|
408
|
+
# an `action_missing` handler or a template answers an action with no
|
|
409
|
+
# method, and a controller that cannot list its actions is assumed to
|
|
410
|
+
# define them all. A second definition here would drift from what
|
|
411
|
+
# permittable:audit then calls stale.
|
|
412
|
+
#
|
|
413
|
+
# Reading it can build the controller (see Audit.dispatchable?), which
|
|
414
|
+
# rescues only a StandardError. A controller whose `initialize` raises a
|
|
415
|
+
# ScriptError, such as NotImplementedError, would abort the whole sweep,
|
|
416
|
+
# which never built a controller before. It is inconclusive, so it is
|
|
417
|
+
# assumed to define both, the same answer as one that cannot list them.
|
|
418
|
+
def undefined_actions(controller)
|
|
419
|
+
missing = Audit.missing_lookup(controller)
|
|
420
|
+
DEFAULT_ACTIONS.select { |action| missing[action.to_s] }
|
|
421
|
+
rescue StandardError, ScriptError
|
|
422
|
+
[]
|
|
423
|
+
end
|
|
424
|
+
|
|
336
425
|
# The core: knowledge in (columns and/or a scan), snippet out. Returns a
|
|
337
426
|
# String of valid Ruby, or nil when neither source of knowledge exists —
|
|
338
427
|
# or when neither can declare a single field, since a contract of only
|
|
@@ -342,16 +431,22 @@ module Permittable
|
|
|
342
431
|
columns = columns_for(model)
|
|
343
432
|
scan = nil unless scan&.found?
|
|
344
433
|
return nil unless columns || scan
|
|
345
|
-
return column_draft(model, columns
|
|
434
|
+
return column_draft(model, unassigned(columns), [], except: except) unless scan
|
|
346
435
|
|
|
347
|
-
shared = scanned_lines(scan, listed_columns(columns, :shared))
|
|
436
|
+
shared = scanned_lines(scan, listed_columns(columns, :shared), model: model)
|
|
348
437
|
return fallback_draft(model, scan, columns, except: except) unless declares_field?(shared)
|
|
349
438
|
|
|
350
439
|
render(root: scan.root, model: columns && model, except: except) do |rule|
|
|
351
|
-
scanned_lines(scan, listed_columns(columns, rule))
|
|
440
|
+
scanned_lines(scan, listed_columns(columns, rule), model: model)
|
|
352
441
|
end
|
|
353
442
|
end
|
|
354
443
|
|
|
444
|
+
# The columns worth drafting without a permit call to name them: not the
|
|
445
|
+
# ones the database or Rails assigns (see DraftColumn).
|
|
446
|
+
def unassigned(columns)
|
|
447
|
+
columns.values.reject(&:assigned)
|
|
448
|
+
end
|
|
449
|
+
|
|
355
450
|
# The columns as one rule drafts them (see DraftColumn#in_rule), each
|
|
356
451
|
# marked listed: on the scan path a column is drafted only because the
|
|
357
452
|
# controller's own calls name it.
|
|
@@ -387,7 +482,7 @@ module Permittable
|
|
|
387
482
|
|
|
388
483
|
root = scan.root || (rootless_body?(scan) ? nil : default_root(model))
|
|
389
484
|
undrafted = (Array(scan.undecided) + Array(scan.route_params).filter_map { |arg| scalar_key(arg) }).map(&:to_s)
|
|
390
|
-
drafted = columns.except(*undrafted)
|
|
485
|
+
drafted = columns.reject { |_, column| column.assigned }.except(*undrafted)
|
|
391
486
|
# As in drop_drafted_todos: a rootless key the columns now declare is
|
|
392
487
|
# not also a "not in this contract" TODO.
|
|
393
488
|
scan = scan.dup.tap { |copy| copy.rootless = Array(scan.rootless).reject { |arg| drafted.key?(scalar_key(arg).to_s) } }
|
|
@@ -416,9 +511,13 @@ module Permittable
|
|
|
416
511
|
model.respond_to?(:columns) ? model : nil
|
|
417
512
|
end
|
|
418
513
|
|
|
419
|
-
#
|
|
420
|
-
#
|
|
421
|
-
#
|
|
514
|
+
# Every column, keyed by name — or nil when there is no model or its
|
|
515
|
+
# schema is unreachable (same philosophy as the drift guard: never let
|
|
516
|
+
# generation crash on a half-migrated database). All of them, the
|
|
517
|
+
# primary key and timestamps too: this is also where a permitted key's
|
|
518
|
+
# type is looked up, and a column left out here read as "not a
|
|
519
|
+
# database column". What is drafted without a permit call is narrowed
|
|
520
|
+
# later (see DraftColumn#assigned).
|
|
422
521
|
def columns_for(model)
|
|
423
522
|
columns = schema_columns(model)
|
|
424
523
|
columns&.to_h { |column| [column.name, draft_column(model, column)] }
|
|
@@ -441,10 +540,7 @@ module Permittable
|
|
|
441
540
|
return nil unless model.respond_to?(:columns)
|
|
442
541
|
return nil unless model.table_exists?
|
|
443
542
|
|
|
444
|
-
|
|
445
|
-
# into its column names; a nil primary key becomes [].
|
|
446
|
-
skipped = SKIPPED_COLUMNS + Array(model.primary_key).map(&:to_s)
|
|
447
|
-
model.columns.reject { |c| skipped.include?(c.name) }
|
|
543
|
+
model.columns
|
|
448
544
|
rescue StandardError => e
|
|
449
545
|
raise unless defined?(ActiveRecord::ActiveRecordError) && e.is_a?(ActiveRecord::ActiveRecordError)
|
|
450
546
|
|
|
@@ -458,7 +554,38 @@ module Permittable
|
|
|
458
554
|
def draft_column(model, column)
|
|
459
555
|
DraftColumn.new(name: column.name, type: column.type, null: column.null,
|
|
460
556
|
default_function: column.respond_to?(:default_function) && column.default_function,
|
|
461
|
-
sensitive: sensitive_role(model, column.name),
|
|
557
|
+
sensitive: sensitive_role(model, column.name), assigned: assigned?(model, column),
|
|
558
|
+
**model_facts(model, column))
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# Whether the database or Rails fills the column in, so no client has to
|
|
562
|
+
# send it. A timestamp is; a primary key only when the database really
|
|
563
|
+
# generates it: an auto-increment column (the adapter says so, wherever
|
|
564
|
+
# it sits in the key: MySQL allows `PRIMARY KEY (region, id)` with `id
|
|
565
|
+
# AUTO_INCREMENT`), or a key with a default or a default function (a
|
|
566
|
+
# uuid). An adapter without that flag falls back to the integer of a
|
|
567
|
+
# single-column key. A natural key (`sku`, a `create_table ..., id:
|
|
568
|
+
# :string`) or a part of a composite key with no such flag is the
|
|
569
|
+
# client's to send, and is drafted like any other column.
|
|
570
|
+
# Array() flattens a composite primary key (an Array in Rails 7.1+) into
|
|
571
|
+
# its column names; a nil primary key becomes [].
|
|
572
|
+
def assigned?(model, column)
|
|
573
|
+
return true if SKIPPED_COLUMNS.include?(column.name)
|
|
574
|
+
|
|
575
|
+
keys = Array(model.primary_key).map(&:to_s)
|
|
576
|
+
return false unless keys.include?(column.name)
|
|
577
|
+
|
|
578
|
+
auto_increment?(column, sole_key: keys.one?) || !column.default.nil? || default_function?(column)
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def auto_increment?(column, sole_key:)
|
|
582
|
+
return column.auto_increment? if column.respond_to?(:auto_increment?)
|
|
583
|
+
|
|
584
|
+
sole_key && column.type == :integer
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def default_function?(column)
|
|
588
|
+
column.respond_to?(:default_function) && !column.default_function.nil?
|
|
462
589
|
end
|
|
463
590
|
|
|
464
591
|
# What the model adds to one column. An error reading it degrades THAT
|
|
@@ -592,20 +719,43 @@ module Permittable
|
|
|
592
719
|
# -- scan parsing -------------------------------------------------------
|
|
593
720
|
|
|
594
721
|
# Split a permit argument list on top-level commas only, so `address:
|
|
595
|
-
# [:city, :zip]` stays one argument.
|
|
722
|
+
# [:city, :zip]` stays one argument. A list argument is replaced by its
|
|
723
|
+
# elements (see LIST_ARG).
|
|
596
724
|
def split_args(args)
|
|
725
|
+
split_top_level(args).flat_map do |part|
|
|
726
|
+
list = LIST_ARG.match(bracket_word_lists(part))
|
|
727
|
+
list ? split_args(list[1]) : [part]
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
# The top-level, comma-separated parts of `args`, as written. A word list
|
|
732
|
+
# delimited by commas (`%w,name email,`) is kept whole, so it stays one
|
|
733
|
+
# unreadable argument rather than the TODOs of its pieces (see WORD_LIST).
|
|
734
|
+
def split_top_level(args)
|
|
597
735
|
parts = [+""]
|
|
598
736
|
depth = 0
|
|
599
|
-
args.
|
|
600
|
-
depth += 1 if "[{".include?(
|
|
601
|
-
depth -= 1 if "]}".include?(
|
|
602
|
-
next parts << +"" if
|
|
737
|
+
args.scan(/%[iIwW],[^,]*,|./m) do |token|
|
|
738
|
+
depth += 1 if token.size == 1 && "[{".include?(token)
|
|
739
|
+
depth -= 1 if token.size == 1 && "]}".include?(token)
|
|
740
|
+
next parts << +"" if token == "," && depth.zero?
|
|
603
741
|
|
|
604
|
-
parts.last <<
|
|
742
|
+
parts.last << token
|
|
605
743
|
end
|
|
606
744
|
parts.map(&:strip).reject(&:empty?)
|
|
607
745
|
end
|
|
608
746
|
|
|
747
|
+
# `text` with each word list of plain keys spelled as the list it equals:
|
|
748
|
+
# `%i[name email]` as `[:name, :email]`, `%w[]` as `[]`, so the shapes
|
|
749
|
+
# above read it. A word list holding any other word — quoted, escaped or
|
|
750
|
+
# interpolated — is left as written, and stays unparsed. Only for
|
|
751
|
+
# matching: a TODO quotes the argument as the source spells it.
|
|
752
|
+
def bracket_word_lists(text)
|
|
753
|
+
text.gsub(WORD_LIST) do |list|
|
|
754
|
+
words = list[3...-1].split
|
|
755
|
+
words.all? { |word| word.match?(/\A\w+\z/) } ? "[#{words.map { |word| ":#{word}" }.join(', ')}]" : list
|
|
756
|
+
end
|
|
757
|
+
end
|
|
758
|
+
|
|
609
759
|
# An expect call's arguments, as one Call per envelope. The bracketed
|
|
610
760
|
# arguments are required root envelopes and their contents are fields.
|
|
611
761
|
# Plain symbols are fields only when there is no envelope
|
|
@@ -625,12 +775,26 @@ module Permittable
|
|
|
625
775
|
end
|
|
626
776
|
|
|
627
777
|
# An expect argument as `[root, fields]` when it is an envelope, else nil.
|
|
628
|
-
#
|
|
778
|
+
# An opaque envelope's one "field" is its value, kept as unparsable.
|
|
779
|
+
#
|
|
780
|
+
# The shape is judged with word lists bracketed: `user: %i[name email]`
|
|
781
|
+
# is the envelope `user: [:name, :email]`, and `items: [%i[sku]]` the
|
|
782
|
+
# array-of-hashes root `items: [[:sku]]`. It is judged on the elements
|
|
783
|
+
# the outer list holds, as Rails does, not on the flattened fields: the
|
|
784
|
+
# list is an array of hashes (or empty) only when it holds nothing but one
|
|
785
|
+
# list, so `user: [%i[name email], :age]` is still the `user` envelope
|
|
786
|
+
# with three fields. A bracketed envelope's fields are split as the source
|
|
787
|
+
# spells them, so a TODO for one quotes it as written.
|
|
629
788
|
def envelope(arg)
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
789
|
+
listed = bracket_word_lists(arg)
|
|
790
|
+
if (match = EXPECT_ENVELOPE.match(listed))
|
|
791
|
+
elements = split_top_level(match[2])
|
|
792
|
+
return if elements.empty? || (elements.one? && elements.first.start_with?("["))
|
|
793
|
+
|
|
794
|
+
written = EXPECT_ENVELOPE.match(arg) || match
|
|
795
|
+
[match[1].to_sym, split_args(written[2])]
|
|
796
|
+
elsif (match = EXPECT_OPAQUE_ENVELOPE.match(listed))
|
|
797
|
+
[match[1].to_sym, [arg.sub(/\A\w+:\s*/, "")]]
|
|
634
798
|
end
|
|
635
799
|
end
|
|
636
800
|
|
|
@@ -640,13 +804,59 @@ module Permittable
|
|
|
640
804
|
# own text may hold placeholders rather than a real string argument's
|
|
641
805
|
# characters (see masked_source), so every group is read back out of
|
|
642
806
|
# `source` by position instead of off `match` directly.
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
807
|
+
# nil when the call cannot be read: a paren-less one whose arguments
|
|
808
|
+
# hold a method call, or a String root that is not a plain key.
|
|
809
|
+
def permit_call(source, masked, match)
|
|
810
|
+
args, finish = call_args(source, masked, match, 4)
|
|
811
|
+
return nil unless args
|
|
812
|
+
|
|
813
|
+
root = [1, 2, 3].filter_map { |index| group(source, match, index) }.first
|
|
814
|
+
return Call.new(match.begin(0), nil, split_args(args), []) unless root
|
|
815
|
+
return nil unless root.match?(/\A\w+\z/)
|
|
816
|
+
|
|
817
|
+
spelling = unparsed_arg(source[match.begin(0)...finish]).gsub(/\(\s+/, "(").gsub(/\s+\)/, ")")
|
|
818
|
+
Call.new(match.begin(0), root.to_sym, split_args(args), [], spelling)
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
# A call's argument text and where the call ends, or nil when it cannot
|
|
822
|
+
# be read. Parenthesised, that is the paren group. Without parentheses
|
|
823
|
+
# it runs from the match to the end of the statement (bare_args_end),
|
|
824
|
+
# less any trailing modifier.
|
|
825
|
+
def call_args(source, masked, match, paren_group)
|
|
826
|
+
return [group(source, match, paren_group), match.end(0)] if match.begin(paren_group)
|
|
827
|
+
|
|
828
|
+
finish = bare_args_end(masked, match.end(0))
|
|
829
|
+
return nil unless finish
|
|
830
|
+
|
|
831
|
+
# The call ends where its arguments do, so a TODO quoting it never
|
|
832
|
+
# quotes the modifier.
|
|
833
|
+
args = source[match.end(0)...finish].sub(BARE_MODIFIER, "").rstrip
|
|
834
|
+
args.empty? ? nil : [args, match.end(0) + args.length]
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
# Where a paren-less argument list ends: the first newline (or `;`)
|
|
838
|
+
# outside brackets that does not follow a `,` or a `\` — Ruby's own rule
|
|
839
|
+
# for continuing a command call onto the next line. A parenthesis means
|
|
840
|
+
# a method call in the arguments, and the call is skipped as the
|
|
841
|
+
# parenthesised form skips one; so are unbalanced brackets. Read on the
|
|
842
|
+
# masked source, where string content cannot open a bracket.
|
|
843
|
+
def bare_args_end(text, start)
|
|
844
|
+
depth = 0
|
|
845
|
+
last = nil
|
|
846
|
+
index = start
|
|
847
|
+
while index < text.length
|
|
848
|
+
char = text[index]
|
|
849
|
+
return nil if "()".include?(char)
|
|
647
850
|
|
|
648
|
-
|
|
649
|
-
|
|
851
|
+
depth += 1 if "[{".include?(char)
|
|
852
|
+
depth -= 1 if "]}".include?(char)
|
|
853
|
+
return nil if depth.negative?
|
|
854
|
+
break if depth.zero? && (char == ";" || (char == "\n" && !(last && ",\\".include?(last))))
|
|
855
|
+
|
|
856
|
+
last = char unless char.match?(/\s/)
|
|
857
|
+
index += 1
|
|
858
|
+
end
|
|
859
|
+
depth.zero? ? index : nil
|
|
650
860
|
end
|
|
651
861
|
|
|
652
862
|
# A rootless expect call — or, when its one key is a route param
|
|
@@ -809,8 +1019,10 @@ module Permittable
|
|
|
809
1019
|
|
|
810
1020
|
# One permit argument as `[shape, key]`, or `[shape, key, sub_keys]` for
|
|
811
1021
|
# the two hash shapes — nil when the conservative parser cannot read it,
|
|
812
|
-
# including a nested list with any sub-key it cannot read.
|
|
1022
|
+
# including a nested list with any sub-key it cannot read. A word list
|
|
1023
|
+
# reads as the list it equals: `address: %i[city zip]` is a nested hash.
|
|
813
1024
|
def parse_arg(arg)
|
|
1025
|
+
arg = bracket_word_lists(arg)
|
|
814
1026
|
if (key = scalar_key(arg))
|
|
815
1027
|
[:scalars, key]
|
|
816
1028
|
elsif (match = ARRAY_ARG.match(arg))
|
|
@@ -924,9 +1136,15 @@ module Permittable
|
|
|
924
1136
|
# it back to its key. Left as a TODO rather than drafted: forms, the
|
|
925
1137
|
# common client, send the key. Only for an enum that stores Integers — a
|
|
926
1138
|
# string-backed one has no second spelling to admit.
|
|
1139
|
+
#
|
|
1140
|
+
# The transform is spelled out because the obvious one is wrong: with
|
|
1141
|
+
# `key(v.to_i) || v`, "shipped".to_i is 0, so a NAME became the first
|
|
1142
|
+
# key. Integer(v, exception: false) is nil for a name, which key() maps
|
|
1143
|
+
# to nil. The type guard accepts this in: only beside a transform:.
|
|
927
1144
|
def enum_todo(column)
|
|
928
1145
|
"TODO: Rails also assigns the stored integers (#{column.name}: 1) — if API clients send them, add " \
|
|
929
|
-
"#{column.enum}.values.map(&:to_s) to in: and map them back to keys with
|
|
1146
|
+
"#{column.enum}.values.map(&:to_s) to in: and map them back to keys with " \
|
|
1147
|
+
"transform: ->(v) { #{column.enum}.key(Integer(v, exception: false)) || v }"
|
|
930
1148
|
end
|
|
931
1149
|
|
|
932
1150
|
def unread_todo(column)
|
|
@@ -940,7 +1158,7 @@ module Permittable
|
|
|
940
1158
|
# request that omits the field, which would overwrite columns on partial
|
|
941
1159
|
# updates; the database already handles creation.
|
|
942
1160
|
def required_column?(column)
|
|
943
|
-
return false if column.null
|
|
1161
|
+
return false if column.null || column.assigned
|
|
944
1162
|
return false unless column.default.nil?
|
|
945
1163
|
|
|
946
1164
|
!column.default_function
|
|
@@ -949,13 +1167,13 @@ module Permittable
|
|
|
949
1167
|
# Scanned names are emitted with Symbol#inspect, not as `:#{name}`: a
|
|
950
1168
|
# string-keyed `permit("2fa")` scans to a name that is not a bare symbol
|
|
951
1169
|
# literal, and `:2fa` would make the whole draft a SyntaxError.
|
|
952
|
-
def scanned_lines(scan, columns)
|
|
1170
|
+
def scanned_lines(scan, columns, model: nil)
|
|
953
1171
|
lines = scan.scalars.map { |name| scanned_scalar_line(name, columns) }
|
|
954
1172
|
lines += scan.arrays.map do |name|
|
|
955
1173
|
"array #{name.to_sym.inspect}, of: :string " \
|
|
956
1174
|
"# TODO: confirm the element type, and declare length: — an array without one is unbounded"
|
|
957
1175
|
end
|
|
958
|
-
scan.nested.each { |name, keys| lines += nested_lines(name, keys) }
|
|
1176
|
+
scan.nested.each { |name, keys| lines += nested_lines(name, keys, model: model) }
|
|
959
1177
|
scan.nested_arrays.each { |name, keys| lines += nested_array_lines(name, keys) }
|
|
960
1178
|
lines + scan_todo_lines(scan)
|
|
961
1179
|
end
|
|
@@ -994,13 +1212,28 @@ module Permittable
|
|
|
994
1212
|
"#{field} # TODO: confirm the type"
|
|
995
1213
|
end
|
|
996
1214
|
|
|
997
|
-
|
|
1215
|
+
# `permit(addresses_attributes: [...])` cannot say whether it permits one
|
|
1216
|
+
# hash or many; the model can. When the key is a collection's nested
|
|
1217
|
+
# attributes (has_many + accepts_nested_attributes_for), fields_for
|
|
1218
|
+
# sends its records as `{"0" => {...}, "1" => {...}}`, which a nested
|
|
1219
|
+
# block reads as unknown keys and silently drops, and an `array ... do`
|
|
1220
|
+
# field reads as the records it encodes. A has_one's stay a nested block.
|
|
1221
|
+
def nested_lines(name, keys, model: nil)
|
|
1222
|
+
return nested_array_lines(name, keys) if collection_attributes?(model, name)
|
|
1223
|
+
|
|
998
1224
|
["optional #{name.to_sym.inspect} do # TODO: drafted from `#{name}: [...]` — " \
|
|
999
1225
|
"if this is an array of hashes, use `array #{name.to_sym.inspect} do`"] +
|
|
1000
1226
|
sub_field_lines(keys) +
|
|
1001
1227
|
["end"]
|
|
1002
1228
|
end
|
|
1003
1229
|
|
|
1230
|
+
def collection_attributes?(model, name)
|
|
1231
|
+
base = name.to_s.delete_suffix("_attributes")
|
|
1232
|
+
return false if base == name.to_s || !model.respond_to?(:reflect_on_association)
|
|
1233
|
+
|
|
1234
|
+
model.reflect_on_association(base.to_sym)&.collection? || false
|
|
1235
|
+
end
|
|
1236
|
+
|
|
1004
1237
|
# No TODO on the kind here, unlike nested_lines: `params.expect` spells an
|
|
1005
1238
|
# array of hashes `#{name}: [[...]]`, which says definitively what the
|
|
1006
1239
|
# equivalent permit call (`#{name}: [...]`) leaves ambiguous.
|