permittable 0.10.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 +72 -0
- data/README.md +435 -32
- data/lib/permittable/audit.rb +241 -24
- 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 +335 -66
- data/lib/permittable/json_schema.rb +102 -15
- data/lib/permittable/open_api.rb +470 -81
- data/lib/permittable/railtie.rb +87 -0
- data/lib/permittable/rspec.rb +49 -7
- data/lib/permittable/tasks/generate.rake +11 -6
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +367 -69
- 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)
|
|
@@ -297,33 +351,100 @@ module Permittable
|
|
|
297
351
|
# nil, until one drafts or none is left: a draft rooted at the other
|
|
298
352
|
# envelope in the file is a better starting point than no draft, and it
|
|
299
353
|
# is what master drafted.
|
|
300
|
-
|
|
354
|
+
#
|
|
355
|
+
# `except:` names actions of DEFAULT_ACTIONS to leave out of the draft —
|
|
356
|
+
# the ones Permittable.audit_ignore names (see targets). With none left
|
|
357
|
+
# there is nothing to draft.
|
|
358
|
+
def for_controller(controller, source: nil, model: nil, except: [])
|
|
359
|
+
return nil if (DEFAULT_ACTIONS - except.map(&:to_sym)).empty?
|
|
360
|
+
|
|
301
361
|
model ||= infer_model(controller)
|
|
302
362
|
excluded = []
|
|
303
363
|
loop do
|
|
304
364
|
scan = scan(source, model: model, exclude: excluded)
|
|
305
|
-
result = draft(model: model, scan: scan)
|
|
365
|
+
result = draft(model: model, scan: scan, except: except)
|
|
306
366
|
return result if result || !scan.found? || excluded.include?(scan.root)
|
|
307
367
|
|
|
308
368
|
excluded << scan.root
|
|
309
369
|
end
|
|
310
370
|
end
|
|
311
371
|
|
|
372
|
+
# What `permittable:generate` drafts when no controller is named, as
|
|
373
|
+
# { controller => actions to leave out of its draft }: every controller
|
|
374
|
+
# with no contract yet, less what the audit ignores (Audit.ignore_reason).
|
|
375
|
+
# ActiveStorage's direct uploads controller is the case this exists for —
|
|
376
|
+
# it was drafted, and a contract pasted into a gem's controller is not
|
|
377
|
+
# one the app can keep. A "controller#action" entry takes only that
|
|
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.
|
|
385
|
+
def targets(controllers, ignore: Permittable.audit_ignore, root: Audit.app_root)
|
|
386
|
+
controllers.each_with_object({}) do |controller, found|
|
|
387
|
+
next if controller.respond_to?(:permittable_contracts) && controller.permittable_contracts.any?
|
|
388
|
+
|
|
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
|
|
393
|
+
found[controller] = skipped unless skipped == DEFAULT_ACTIONS
|
|
394
|
+
end
|
|
395
|
+
end
|
|
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
|
+
|
|
312
425
|
# The core: knowledge in (columns and/or a scan), snippet out. Returns a
|
|
313
426
|
# String of valid Ruby, or nil when neither source of knowledge exists —
|
|
314
427
|
# or when neither can declare a single field, since a contract of only
|
|
315
428
|
# TODO lines raises `a contract must declare at least one field` the
|
|
316
429
|
# moment it is pasted.
|
|
317
|
-
def draft(model: nil, scan: nil)
|
|
430
|
+
def draft(model: nil, scan: nil, except: [])
|
|
318
431
|
columns = columns_for(model)
|
|
319
432
|
scan = nil unless scan&.found?
|
|
320
433
|
return nil unless columns || scan
|
|
321
|
-
return column_draft(model, columns
|
|
434
|
+
return column_draft(model, unassigned(columns), [], except: except) unless scan
|
|
435
|
+
|
|
436
|
+
shared = scanned_lines(scan, listed_columns(columns, :shared), model: model)
|
|
437
|
+
return fallback_draft(model, scan, columns, except: except) unless declares_field?(shared)
|
|
322
438
|
|
|
323
|
-
|
|
324
|
-
|
|
439
|
+
render(root: scan.root, model: columns && model, except: except) do |rule|
|
|
440
|
+
scanned_lines(scan, listed_columns(columns, rule), model: model)
|
|
441
|
+
end
|
|
442
|
+
end
|
|
325
443
|
|
|
326
|
-
|
|
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)
|
|
327
448
|
end
|
|
328
449
|
|
|
329
450
|
# The columns as one rule drafts them (see DraftColumn#in_rule), each
|
|
@@ -356,16 +477,16 @@ module Permittable
|
|
|
356
477
|
# `permit(policy(@post).permitted_attributes)` the scanner skips — says
|
|
357
478
|
# nothing about the body, so it gets the model's root, as master drafted.
|
|
358
479
|
# With no columns either there is nothing loadable to draft, so nil.
|
|
359
|
-
def fallback_draft(model, scan, columns)
|
|
480
|
+
def fallback_draft(model, scan, columns, except: [])
|
|
360
481
|
return nil unless columns
|
|
361
482
|
|
|
362
483
|
root = scan.root || (rootless_body?(scan) ? nil : default_root(model))
|
|
363
484
|
undrafted = (Array(scan.undecided) + Array(scan.route_params).filter_map { |arg| scalar_key(arg) }).map(&:to_s)
|
|
364
|
-
drafted = columns.except(*undrafted)
|
|
485
|
+
drafted = columns.reject { |_, column| column.assigned }.except(*undrafted)
|
|
365
486
|
# As in drop_drafted_todos: a rootless key the columns now declare is
|
|
366
487
|
# not also a "not in this contract" TODO.
|
|
367
488
|
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)
|
|
489
|
+
column_draft(model, drafted.values, scan_todo_lines(scan), root: root, except: except)
|
|
369
490
|
end
|
|
370
491
|
|
|
371
492
|
# Whether a rootless scan's calls carried at least one body field —
|
|
@@ -377,8 +498,10 @@ module Permittable
|
|
|
377
498
|
|
|
378
499
|
# A draft from the columns, or nil when none of them has a contract type
|
|
379
500
|
# 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)
|
|
501
|
+
def column_draft(model, columns, todos, root: default_root(model), except: [])
|
|
502
|
+
render(root: root, model: model, except: except) do |rule|
|
|
503
|
+
column_lines(columns.map { |column| column.in_rule(rule) }) + todos
|
|
504
|
+
end
|
|
382
505
|
end
|
|
383
506
|
|
|
384
507
|
def infer_model(controller)
|
|
@@ -388,9 +511,13 @@ module Permittable
|
|
|
388
511
|
model.respond_to?(:columns) ? model : nil
|
|
389
512
|
end
|
|
390
513
|
|
|
391
|
-
#
|
|
392
|
-
#
|
|
393
|
-
#
|
|
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).
|
|
394
521
|
def columns_for(model)
|
|
395
522
|
columns = schema_columns(model)
|
|
396
523
|
columns&.to_h { |column| [column.name, draft_column(model, column)] }
|
|
@@ -413,10 +540,7 @@ module Permittable
|
|
|
413
540
|
return nil unless model.respond_to?(:columns)
|
|
414
541
|
return nil unless model.table_exists?
|
|
415
542
|
|
|
416
|
-
|
|
417
|
-
# into its column names; a nil primary key becomes [].
|
|
418
|
-
skipped = SKIPPED_COLUMNS + Array(model.primary_key).map(&:to_s)
|
|
419
|
-
model.columns.reject { |c| skipped.include?(c.name) }
|
|
543
|
+
model.columns
|
|
420
544
|
rescue StandardError => e
|
|
421
545
|
raise unless defined?(ActiveRecord::ActiveRecordError) && e.is_a?(ActiveRecord::ActiveRecordError)
|
|
422
546
|
|
|
@@ -430,7 +554,38 @@ module Permittable
|
|
|
430
554
|
def draft_column(model, column)
|
|
431
555
|
DraftColumn.new(name: column.name, type: column.type, null: column.null,
|
|
432
556
|
default_function: column.respond_to?(:default_function) && column.default_function,
|
|
433
|
-
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?
|
|
434
589
|
end
|
|
435
590
|
|
|
436
591
|
# What the model adds to one column. An error reading it degrades THAT
|
|
@@ -564,20 +719,43 @@ module Permittable
|
|
|
564
719
|
# -- scan parsing -------------------------------------------------------
|
|
565
720
|
|
|
566
721
|
# Split a permit argument list on top-level commas only, so `address:
|
|
567
|
-
# [:city, :zip]` stays one argument.
|
|
722
|
+
# [:city, :zip]` stays one argument. A list argument is replaced by its
|
|
723
|
+
# elements (see LIST_ARG).
|
|
568
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)
|
|
569
735
|
parts = [+""]
|
|
570
736
|
depth = 0
|
|
571
|
-
args.
|
|
572
|
-
depth += 1 if "[{".include?(
|
|
573
|
-
depth -= 1 if "]}".include?(
|
|
574
|
-
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?
|
|
575
741
|
|
|
576
|
-
parts.last <<
|
|
742
|
+
parts.last << token
|
|
577
743
|
end
|
|
578
744
|
parts.map(&:strip).reject(&:empty?)
|
|
579
745
|
end
|
|
580
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
|
+
|
|
581
759
|
# An expect call's arguments, as one Call per envelope. The bracketed
|
|
582
760
|
# arguments are required root envelopes and their contents are fields.
|
|
583
761
|
# Plain symbols are fields only when there is no envelope
|
|
@@ -597,12 +775,26 @@ module Permittable
|
|
|
597
775
|
end
|
|
598
776
|
|
|
599
777
|
# An expect argument as `[root, fields]` when it is an envelope, else nil.
|
|
600
|
-
#
|
|
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.
|
|
601
788
|
def envelope(arg)
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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*/, "")]]
|
|
606
798
|
end
|
|
607
799
|
end
|
|
608
800
|
|
|
@@ -612,13 +804,59 @@ module Permittable
|
|
|
612
804
|
# own text may hold placeholders rather than a real string argument's
|
|
613
805
|
# characters (see masked_source), so every group is read back out of
|
|
614
806
|
# `source` by position instead of off `match` directly.
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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)
|
|
850
|
+
|
|
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))))
|
|
619
855
|
|
|
620
|
-
|
|
621
|
-
|
|
856
|
+
last = char unless char.match?(/\s/)
|
|
857
|
+
index += 1
|
|
858
|
+
end
|
|
859
|
+
depth.zero? ? index : nil
|
|
622
860
|
end
|
|
623
861
|
|
|
624
862
|
# A rootless expect call — or, when its one key is a route param
|
|
@@ -781,8 +1019,10 @@ module Permittable
|
|
|
781
1019
|
|
|
782
1020
|
# One permit argument as `[shape, key]`, or `[shape, key, sub_keys]` for
|
|
783
1021
|
# the two hash shapes — nil when the conservative parser cannot read it,
|
|
784
|
-
# 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.
|
|
785
1024
|
def parse_arg(arg)
|
|
1025
|
+
arg = bracket_word_lists(arg)
|
|
786
1026
|
if (key = scalar_key(arg))
|
|
787
1027
|
[:scalars, key]
|
|
788
1028
|
elsif (match = ARRAY_ARG.match(arg))
|
|
@@ -896,9 +1136,15 @@ module Permittable
|
|
|
896
1136
|
# it back to its key. Left as a TODO rather than drafted: forms, the
|
|
897
1137
|
# common client, send the key. Only for an enum that stores Integers — a
|
|
898
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:.
|
|
899
1144
|
def enum_todo(column)
|
|
900
1145
|
"TODO: Rails also assigns the stored integers (#{column.name}: 1) — if API clients send them, add " \
|
|
901
|
-
"#{column.enum}.values.map(&:to_s) to in: and map them back to keys with
|
|
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 }"
|
|
902
1148
|
end
|
|
903
1149
|
|
|
904
1150
|
def unread_todo(column)
|
|
@@ -912,7 +1158,7 @@ module Permittable
|
|
|
912
1158
|
# request that omits the field, which would overwrite columns on partial
|
|
913
1159
|
# updates; the database already handles creation.
|
|
914
1160
|
def required_column?(column)
|
|
915
|
-
return false if column.null
|
|
1161
|
+
return false if column.null || column.assigned
|
|
916
1162
|
return false unless column.default.nil?
|
|
917
1163
|
|
|
918
1164
|
!column.default_function
|
|
@@ -921,13 +1167,13 @@ module Permittable
|
|
|
921
1167
|
# Scanned names are emitted with Symbol#inspect, not as `:#{name}`: a
|
|
922
1168
|
# string-keyed `permit("2fa")` scans to a name that is not a bare symbol
|
|
923
1169
|
# literal, and `:2fa` would make the whole draft a SyntaxError.
|
|
924
|
-
def scanned_lines(scan, columns)
|
|
1170
|
+
def scanned_lines(scan, columns, model: nil)
|
|
925
1171
|
lines = scan.scalars.map { |name| scanned_scalar_line(name, columns) }
|
|
926
1172
|
lines += scan.arrays.map do |name|
|
|
927
1173
|
"array #{name.to_sym.inspect}, of: :string " \
|
|
928
1174
|
"# TODO: confirm the element type, and declare length: — an array without one is unbounded"
|
|
929
1175
|
end
|
|
930
|
-
scan.nested.each { |name, keys| lines += nested_lines(name, keys) }
|
|
1176
|
+
scan.nested.each { |name, keys| lines += nested_lines(name, keys, model: model) }
|
|
931
1177
|
scan.nested_arrays.each { |name, keys| lines += nested_array_lines(name, keys) }
|
|
932
1178
|
lines + scan_todo_lines(scan)
|
|
933
1179
|
end
|
|
@@ -966,13 +1212,28 @@ module Permittable
|
|
|
966
1212
|
"#{field} # TODO: confirm the type"
|
|
967
1213
|
end
|
|
968
1214
|
|
|
969
|
-
|
|
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
|
+
|
|
970
1224
|
["optional #{name.to_sym.inspect} do # TODO: drafted from `#{name}: [...]` — " \
|
|
971
1225
|
"if this is an array of hashes, use `array #{name.to_sym.inspect} do`"] +
|
|
972
1226
|
sub_field_lines(keys) +
|
|
973
1227
|
["end"]
|
|
974
1228
|
end
|
|
975
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
|
+
|
|
976
1237
|
# No TODO on the kind here, unlike nested_lines: `params.expect` spells an
|
|
977
1238
|
# array of hashes `#{name}: [[...]]`, which says definitively what the
|
|
978
1239
|
# equivalent permit call (`#{name}: [...]`) leaves ambiguous.
|
|
@@ -1005,12 +1266,20 @@ module Permittable
|
|
|
1005
1266
|
# no field, which raises `a contract must declare at least one field`
|
|
1006
1267
|
# when pasted. There is then nothing loadable to draft: nil, as for no
|
|
1007
1268
|
# knowledge at all, which the rake task already skips.
|
|
1008
|
-
|
|
1269
|
+
#
|
|
1270
|
+
# An action in `except` is dropped from the rule that declares it, and a
|
|
1271
|
+
# rule left with no action is dropped: excepting :create from a split
|
|
1272
|
+
# draft leaves the :update rule as drafted, with nothing required.
|
|
1273
|
+
def render(root:, model:, except: [], &lines)
|
|
1009
1274
|
shared = lines.call(:shared)
|
|
1010
1275
|
return nil if shared.all? { |line| line.start_with?("#") }
|
|
1011
1276
|
|
|
1012
1277
|
update = lines.call(:update)
|
|
1013
1278
|
rules = shared == update ? [[DEFAULT_ACTIONS, shared]] : [[%i[create], lines.call(:create)], [%i[update], update]]
|
|
1279
|
+
skipped = except.map(&:to_sym)
|
|
1280
|
+
rules = rules.filter_map { |actions, body| [actions - skipped, body] unless (actions - skipped).empty? }
|
|
1281
|
+
return nil if rules.empty?
|
|
1282
|
+
|
|
1014
1283
|
bodies = rules.map do |actions, body|
|
|
1015
1284
|
"#{signature(root: root, model: model, actions: actions)}\n#{body.map { |line| " #{line}\n" }.join}end\n"
|
|
1016
1285
|
end
|