permittable 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/README.md +394 -36
- data/lib/permittable/audit.rb +11 -5
- data/lib/permittable/authored_values.rb +87 -13
- data/lib/permittable/column_guard.rb +60 -11
- data/lib/permittable/contract.rb +54 -2
- data/lib/permittable/error_envelope.rb +97 -8
- data/lib/permittable/field_group.rb +2 -1
- data/lib/permittable/generator.rb +333 -61
- data/lib/permittable/json_schema.rb +116 -17
- data/lib/permittable/open_api.rb +41 -2
- data/lib/permittable/railtie.rb +87 -0
- data/lib/permittable/rspec.rb +201 -36
- data/lib/permittable/tasks/generate.rake +6 -4
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +597 -97
- 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, :array, :array_default, 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,40 @@ 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
|
-
|
|
557
|
+
array: column.respond_to?(:array?) && column.array? == true,
|
|
558
|
+
array_default: array_default?(column.default),
|
|
559
|
+
sensitive: sensitive_role(model, column.name), assigned: assigned?(model, column),
|
|
560
|
+
**model_facts(model, column))
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Whether the database or Rails fills the column in, so no client has to
|
|
564
|
+
# send it. A timestamp is; a primary key only when the database really
|
|
565
|
+
# generates it: an auto-increment column (the adapter says so, wherever
|
|
566
|
+
# it sits in the key: MySQL allows `PRIMARY KEY (region, id)` with `id
|
|
567
|
+
# AUTO_INCREMENT`), or a key with a default or a default function (a
|
|
568
|
+
# uuid). An adapter without that flag falls back to the integer of a
|
|
569
|
+
# single-column key. A natural key (`sku`, a `create_table ..., id:
|
|
570
|
+
# :string`) or a part of a composite key with no such flag is the
|
|
571
|
+
# client's to send, and is drafted like any other column.
|
|
572
|
+
# Array() flattens a composite primary key (an Array in Rails 7.1+) into
|
|
573
|
+
# its column names; a nil primary key becomes [].
|
|
574
|
+
def assigned?(model, column)
|
|
575
|
+
return true if SKIPPED_COLUMNS.include?(column.name)
|
|
576
|
+
|
|
577
|
+
keys = Array(model.primary_key).map(&:to_s)
|
|
578
|
+
return false unless keys.include?(column.name)
|
|
579
|
+
|
|
580
|
+
auto_increment?(column, sole_key: keys.one?) || !column.default.nil? || default_function?(column)
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def auto_increment?(column, sole_key:)
|
|
584
|
+
return column.auto_increment? if column.respond_to?(:auto_increment?)
|
|
585
|
+
|
|
586
|
+
sole_key && column.type == :integer
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def default_function?(column)
|
|
590
|
+
column.respond_to?(:default_function) && !column.default_function.nil?
|
|
462
591
|
end
|
|
463
592
|
|
|
464
593
|
# What the model adds to one column. An error reading it degrades THAT
|
|
@@ -592,20 +721,43 @@ module Permittable
|
|
|
592
721
|
# -- scan parsing -------------------------------------------------------
|
|
593
722
|
|
|
594
723
|
# Split a permit argument list on top-level commas only, so `address:
|
|
595
|
-
# [:city, :zip]` stays one argument.
|
|
724
|
+
# [:city, :zip]` stays one argument. A list argument is replaced by its
|
|
725
|
+
# elements (see LIST_ARG).
|
|
596
726
|
def split_args(args)
|
|
727
|
+
split_top_level(args).flat_map do |part|
|
|
728
|
+
list = LIST_ARG.match(bracket_word_lists(part))
|
|
729
|
+
list ? split_args(list[1]) : [part]
|
|
730
|
+
end
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
# The top-level, comma-separated parts of `args`, as written. A word list
|
|
734
|
+
# delimited by commas (`%w,name email,`) is kept whole, so it stays one
|
|
735
|
+
# unreadable argument rather than the TODOs of its pieces (see WORD_LIST).
|
|
736
|
+
def split_top_level(args)
|
|
597
737
|
parts = [+""]
|
|
598
738
|
depth = 0
|
|
599
|
-
args.
|
|
600
|
-
depth += 1 if "[{".include?(
|
|
601
|
-
depth -= 1 if "]}".include?(
|
|
602
|
-
next parts << +"" if
|
|
739
|
+
args.scan(/%[iIwW],[^,]*,|./m) do |token|
|
|
740
|
+
depth += 1 if token.size == 1 && "[{".include?(token)
|
|
741
|
+
depth -= 1 if token.size == 1 && "]}".include?(token)
|
|
742
|
+
next parts << +"" if token == "," && depth.zero?
|
|
603
743
|
|
|
604
|
-
parts.last <<
|
|
744
|
+
parts.last << token
|
|
605
745
|
end
|
|
606
746
|
parts.map(&:strip).reject(&:empty?)
|
|
607
747
|
end
|
|
608
748
|
|
|
749
|
+
# `text` with each word list of plain keys spelled as the list it equals:
|
|
750
|
+
# `%i[name email]` as `[:name, :email]`, `%w[]` as `[]`, so the shapes
|
|
751
|
+
# above read it. A word list holding any other word — quoted, escaped or
|
|
752
|
+
# interpolated — is left as written, and stays unparsed. Only for
|
|
753
|
+
# matching: a TODO quotes the argument as the source spells it.
|
|
754
|
+
def bracket_word_lists(text)
|
|
755
|
+
text.gsub(WORD_LIST) do |list|
|
|
756
|
+
words = list[3...-1].split
|
|
757
|
+
words.all? { |word| word.match?(/\A\w+\z/) } ? "[#{words.map { |word| ":#{word}" }.join(', ')}]" : list
|
|
758
|
+
end
|
|
759
|
+
end
|
|
760
|
+
|
|
609
761
|
# An expect call's arguments, as one Call per envelope. The bracketed
|
|
610
762
|
# arguments are required root envelopes and their contents are fields.
|
|
611
763
|
# Plain symbols are fields only when there is no envelope
|
|
@@ -625,12 +777,26 @@ module Permittable
|
|
|
625
777
|
end
|
|
626
778
|
|
|
627
779
|
# An expect argument as `[root, fields]` when it is an envelope, else nil.
|
|
628
|
-
#
|
|
780
|
+
# An opaque envelope's one "field" is its value, kept as unparsable.
|
|
781
|
+
#
|
|
782
|
+
# The shape is judged with word lists bracketed: `user: %i[name email]`
|
|
783
|
+
# is the envelope `user: [:name, :email]`, and `items: [%i[sku]]` the
|
|
784
|
+
# array-of-hashes root `items: [[:sku]]`. It is judged on the elements
|
|
785
|
+
# the outer list holds, as Rails does, not on the flattened fields: the
|
|
786
|
+
# list is an array of hashes (or empty) only when it holds nothing but one
|
|
787
|
+
# list, so `user: [%i[name email], :age]` is still the `user` envelope
|
|
788
|
+
# with three fields. A bracketed envelope's fields are split as the source
|
|
789
|
+
# spells them, so a TODO for one quotes it as written.
|
|
629
790
|
def envelope(arg)
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
791
|
+
listed = bracket_word_lists(arg)
|
|
792
|
+
if (match = EXPECT_ENVELOPE.match(listed))
|
|
793
|
+
elements = split_top_level(match[2])
|
|
794
|
+
return if elements.empty? || (elements.one? && elements.first.start_with?("["))
|
|
795
|
+
|
|
796
|
+
written = EXPECT_ENVELOPE.match(arg) || match
|
|
797
|
+
[match[1].to_sym, split_args(written[2])]
|
|
798
|
+
elsif (match = EXPECT_OPAQUE_ENVELOPE.match(listed))
|
|
799
|
+
[match[1].to_sym, [arg.sub(/\A\w+:\s*/, "")]]
|
|
634
800
|
end
|
|
635
801
|
end
|
|
636
802
|
|
|
@@ -640,13 +806,59 @@ module Permittable
|
|
|
640
806
|
# own text may hold placeholders rather than a real string argument's
|
|
641
807
|
# characters (see masked_source), so every group is read back out of
|
|
642
808
|
# `source` by position instead of off `match` directly.
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
809
|
+
# nil when the call cannot be read: a paren-less one whose arguments
|
|
810
|
+
# hold a method call, or a String root that is not a plain key.
|
|
811
|
+
def permit_call(source, masked, match)
|
|
812
|
+
args, finish = call_args(source, masked, match, 4)
|
|
813
|
+
return nil unless args
|
|
814
|
+
|
|
815
|
+
root = [1, 2, 3].filter_map { |index| group(source, match, index) }.first
|
|
816
|
+
return Call.new(match.begin(0), nil, split_args(args), []) unless root
|
|
817
|
+
return nil unless root.match?(/\A\w+\z/)
|
|
818
|
+
|
|
819
|
+
spelling = unparsed_arg(source[match.begin(0)...finish]).gsub(/\(\s+/, "(").gsub(/\s+\)/, ")")
|
|
820
|
+
Call.new(match.begin(0), root.to_sym, split_args(args), [], spelling)
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
# A call's argument text and where the call ends, or nil when it cannot
|
|
824
|
+
# be read. Parenthesised, that is the paren group. Without parentheses
|
|
825
|
+
# it runs from the match to the end of the statement (bare_args_end),
|
|
826
|
+
# less any trailing modifier.
|
|
827
|
+
def call_args(source, masked, match, paren_group)
|
|
828
|
+
return [group(source, match, paren_group), match.end(0)] if match.begin(paren_group)
|
|
829
|
+
|
|
830
|
+
finish = bare_args_end(masked, match.end(0))
|
|
831
|
+
return nil unless finish
|
|
832
|
+
|
|
833
|
+
# The call ends where its arguments do, so a TODO quoting it never
|
|
834
|
+
# quotes the modifier.
|
|
835
|
+
args = source[match.end(0)...finish].sub(BARE_MODIFIER, "").rstrip
|
|
836
|
+
args.empty? ? nil : [args, match.end(0) + args.length]
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
# Where a paren-less argument list ends: the first newline (or `;`)
|
|
840
|
+
# outside brackets that does not follow a `,` or a `\` — Ruby's own rule
|
|
841
|
+
# for continuing a command call onto the next line. A parenthesis means
|
|
842
|
+
# a method call in the arguments, and the call is skipped as the
|
|
843
|
+
# parenthesised form skips one; so are unbalanced brackets. Read on the
|
|
844
|
+
# masked source, where string content cannot open a bracket.
|
|
845
|
+
def bare_args_end(text, start)
|
|
846
|
+
depth = 0
|
|
847
|
+
last = nil
|
|
848
|
+
index = start
|
|
849
|
+
while index < text.length
|
|
850
|
+
char = text[index]
|
|
851
|
+
return nil if "()".include?(char)
|
|
647
852
|
|
|
648
|
-
|
|
649
|
-
|
|
853
|
+
depth += 1 if "[{".include?(char)
|
|
854
|
+
depth -= 1 if "]}".include?(char)
|
|
855
|
+
return nil if depth.negative?
|
|
856
|
+
break if depth.zero? && (char == ";" || (char == "\n" && !(last && ",\\".include?(last))))
|
|
857
|
+
|
|
858
|
+
last = char unless char.match?(/\s/)
|
|
859
|
+
index += 1
|
|
860
|
+
end
|
|
861
|
+
depth.zero? ? index : nil
|
|
650
862
|
end
|
|
651
863
|
|
|
652
864
|
# A rootless expect call — or, when its one key is a route param
|
|
@@ -809,8 +1021,10 @@ module Permittable
|
|
|
809
1021
|
|
|
810
1022
|
# One permit argument as `[shape, key]`, or `[shape, key, sub_keys]` for
|
|
811
1023
|
# the two hash shapes — nil when the conservative parser cannot read it,
|
|
812
|
-
# including a nested list with any sub-key it cannot read.
|
|
1024
|
+
# including a nested list with any sub-key it cannot read. A word list
|
|
1025
|
+
# reads as the list it equals: `address: %i[city zip]` is a nested hash.
|
|
813
1026
|
def parse_arg(arg)
|
|
1027
|
+
arg = bracket_word_lists(arg)
|
|
814
1028
|
if (key = scalar_key(arg))
|
|
815
1029
|
[:scalars, key]
|
|
816
1030
|
elsif (match = ARRAY_ARG.match(arg))
|
|
@@ -872,9 +1086,11 @@ module Permittable
|
|
|
872
1086
|
# does not parse.
|
|
873
1087
|
def column_line(column)
|
|
874
1088
|
return "# TODO: #{column.name} #{omitted_note(column)}" if column.sensitive && !column.listed
|
|
1089
|
+
return array_column_line(column) if column.array
|
|
875
1090
|
|
|
876
1091
|
type = column.enum ? :string : COLUMN_TYPES[column.type]
|
|
877
1092
|
return "# TODO: #{column.name} (#{column.type}) has no contract type — declare it as a nested block or an array" unless type
|
|
1093
|
+
return json_array_todo(column) if type == :json && column.array_default
|
|
878
1094
|
|
|
879
1095
|
required = column.rule != :update && required_column?(column)
|
|
880
1096
|
line = "#{required ? 'required' : 'optional'} #{column.name.to_sym.inspect}, :#{type}"
|
|
@@ -883,6 +1099,41 @@ module Permittable
|
|
|
883
1099
|
notes.empty? ? line : "#{line} # #{notes.join('; ')}"
|
|
884
1100
|
end
|
|
885
1101
|
|
|
1102
|
+
# A PostgreSQL array column (`t.string :tags, array: true`) reports its
|
|
1103
|
+
# ELEMENT type as `type` and says it is an array through `array?`. Drafted
|
|
1104
|
+
# as a scalar, it would reject as invalid_type the very array it stores
|
|
1105
|
+
# the moment the draft is enforced. `of:` takes only a scalar type, so a
|
|
1106
|
+
# json/jsonb/hstore element type (an array of documents) is left as a
|
|
1107
|
+
# TODO, as is any element type with no contract type at all.
|
|
1108
|
+
def array_column_line(column)
|
|
1109
|
+
type = COLUMN_TYPES[column.type]
|
|
1110
|
+
unless type && type != :json
|
|
1111
|
+
return "# TODO: #{column.name} (#{column.type}[]) has no contract type — declare it as a nested block or an array"
|
|
1112
|
+
end
|
|
1113
|
+
|
|
1114
|
+
required = column.rule != :update && required_column?(column)
|
|
1115
|
+
line = "array #{column.name.to_sym.inspect}, of: :#{type}#{', required: true' if required}"
|
|
1116
|
+
notes = [column.default, *column_todos(column), "TODO: declare length: — an array without one is unbounded"]
|
|
1117
|
+
"#{line} # #{notes.compact.join('; ')}"
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
# :json accepts only an object, but a json/jsonb column can as well hold
|
|
1121
|
+
# an array — and when its default is one, it does: drafted as :json, it
|
|
1122
|
+
# rejected as invalid_type, once enforced, what every client sends for
|
|
1123
|
+
# it. Whether the elements are scalars or objects the column cannot say,
|
|
1124
|
+
# so it is a TODO naming both declarations rather than a guess.
|
|
1125
|
+
def json_array_todo(column)
|
|
1126
|
+
name = column.name.to_sym.inspect
|
|
1127
|
+
"# TODO: #{column.name} (#{column.type}) defaults to an array, which :json refuses — declare it " \
|
|
1128
|
+
"`array #{name}, of: :string` (confirm the element type) or `array #{name} do ... end` for objects"
|
|
1129
|
+
end
|
|
1130
|
+
|
|
1131
|
+
# A database default as the adapter reports it: the SQL literal "[]"
|
|
1132
|
+
# through Rails 8.0, possibly the cast Array after.
|
|
1133
|
+
def array_default?(default)
|
|
1134
|
+
default.is_a?(Array) || (default.is_a?(String) && default.lstrip.start_with?("["))
|
|
1135
|
+
end
|
|
1136
|
+
|
|
886
1137
|
# Drafting from columns alone, a sensitive column is left out of the
|
|
887
1138
|
# fields — nothing says any client sends it — and named here instead.
|
|
888
1139
|
# The lock_version note says where to declare it in the rules actually
|
|
@@ -924,9 +1175,15 @@ module Permittable
|
|
|
924
1175
|
# it back to its key. Left as a TODO rather than drafted: forms, the
|
|
925
1176
|
# common client, send the key. Only for an enum that stores Integers — a
|
|
926
1177
|
# string-backed one has no second spelling to admit.
|
|
1178
|
+
#
|
|
1179
|
+
# The transform is spelled out because the obvious one is wrong: with
|
|
1180
|
+
# `key(v.to_i) || v`, "shipped".to_i is 0, so a NAME became the first
|
|
1181
|
+
# key. Integer(v, exception: false) is nil for a name, which key() maps
|
|
1182
|
+
# to nil. The type guard accepts this in: only beside a transform:.
|
|
927
1183
|
def enum_todo(column)
|
|
928
1184
|
"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
|
|
1185
|
+
"#{column.enum}.values.map(&:to_s) to in: and map them back to keys with " \
|
|
1186
|
+
"transform: ->(v) { #{column.enum}.key(Integer(v, exception: false)) || v }"
|
|
930
1187
|
end
|
|
931
1188
|
|
|
932
1189
|
def unread_todo(column)
|
|
@@ -940,7 +1197,7 @@ module Permittable
|
|
|
940
1197
|
# request that omits the field, which would overwrite columns on partial
|
|
941
1198
|
# updates; the database already handles creation.
|
|
942
1199
|
def required_column?(column)
|
|
943
|
-
return false if column.null
|
|
1200
|
+
return false if column.null || column.assigned
|
|
944
1201
|
return false unless column.default.nil?
|
|
945
1202
|
|
|
946
1203
|
!column.default_function
|
|
@@ -949,13 +1206,13 @@ module Permittable
|
|
|
949
1206
|
# Scanned names are emitted with Symbol#inspect, not as `:#{name}`: a
|
|
950
1207
|
# string-keyed `permit("2fa")` scans to a name that is not a bare symbol
|
|
951
1208
|
# literal, and `:2fa` would make the whole draft a SyntaxError.
|
|
952
|
-
def scanned_lines(scan, columns)
|
|
1209
|
+
def scanned_lines(scan, columns, model: nil)
|
|
953
1210
|
lines = scan.scalars.map { |name| scanned_scalar_line(name, columns) }
|
|
954
1211
|
lines += scan.arrays.map do |name|
|
|
955
1212
|
"array #{name.to_sym.inspect}, of: :string " \
|
|
956
1213
|
"# TODO: confirm the element type, and declare length: — an array without one is unbounded"
|
|
957
1214
|
end
|
|
958
|
-
scan.nested.each { |name, keys| lines += nested_lines(name, keys) }
|
|
1215
|
+
scan.nested.each { |name, keys| lines += nested_lines(name, keys, model: model) }
|
|
959
1216
|
scan.nested_arrays.each { |name, keys| lines += nested_array_lines(name, keys) }
|
|
960
1217
|
lines + scan_todo_lines(scan)
|
|
961
1218
|
end
|
|
@@ -994,13 +1251,28 @@ module Permittable
|
|
|
994
1251
|
"#{field} # TODO: confirm the type"
|
|
995
1252
|
end
|
|
996
1253
|
|
|
997
|
-
|
|
1254
|
+
# `permit(addresses_attributes: [...])` cannot say whether it permits one
|
|
1255
|
+
# hash or many; the model can. When the key is a collection's nested
|
|
1256
|
+
# attributes (has_many + accepts_nested_attributes_for), fields_for
|
|
1257
|
+
# sends its records as `{"0" => {...}, "1" => {...}}`, which a nested
|
|
1258
|
+
# block reads as unknown keys and silently drops, and an `array ... do`
|
|
1259
|
+
# field reads as the records it encodes. A has_one's stay a nested block.
|
|
1260
|
+
def nested_lines(name, keys, model: nil)
|
|
1261
|
+
return nested_array_lines(name, keys) if collection_attributes?(model, name)
|
|
1262
|
+
|
|
998
1263
|
["optional #{name.to_sym.inspect} do # TODO: drafted from `#{name}: [...]` — " \
|
|
999
1264
|
"if this is an array of hashes, use `array #{name.to_sym.inspect} do`"] +
|
|
1000
1265
|
sub_field_lines(keys) +
|
|
1001
1266
|
["end"]
|
|
1002
1267
|
end
|
|
1003
1268
|
|
|
1269
|
+
def collection_attributes?(model, name)
|
|
1270
|
+
base = name.to_s.delete_suffix("_attributes")
|
|
1271
|
+
return false if base == name.to_s || !model.respond_to?(:reflect_on_association)
|
|
1272
|
+
|
|
1273
|
+
model.reflect_on_association(base.to_sym)&.collection? || false
|
|
1274
|
+
end
|
|
1275
|
+
|
|
1004
1276
|
# No TODO on the kind here, unlike nested_lines: `params.expect` spells an
|
|
1005
1277
|
# array of hashes `#{name}: [[...]]`, which says definitively what the
|
|
1006
1278
|
# equivalent permit call (`#{name}: [...]`) leaves ambiguous.
|