the_schema_is 0.0.1 → 0.0.5
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 +16 -0
- data/README.md +26 -19
- data/config/defaults.yml +29 -0
- data/lib/the_schema_is/cops/node_util.rb +48 -5
- data/lib/the_schema_is/cops/parser.rb +52 -23
- data/lib/the_schema_is/cops.rb +102 -77
- metadata +8 -20
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8735d476459a14d53240641bec87d03d7dbcd7bd5d3c8508135bde7739a04e80
|
4
|
+
data.tar.gz: 5cb9c6f68830db4c556aabe538611f448c581fd2c94efb15bf6eda0c0ce04511
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c4ad68960b63c360ec9becc3f733e157a6547d55e318b7de37ac9d9ff3f872c436a07729759eeba126613fb812a291f7b76a1b782fa83a82c85977e91f066493
|
7
|
+
data.tar.gz: c37d853e5dfbb790264e146cd07849e959880b46559a92e5b578036b0cb906de4a4db74d4d744c4b8013763782c67f5bb0592f8bd2bb22ed702348ab42fdb1b3
|
data/Changelog.md
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
# the-schema-is changes
|
2
|
+
|
3
|
+
## 2021-11-04 - 0.0.5
|
4
|
+
|
5
|
+
* Support `enum` column type;
|
6
|
+
* Add `RemoveDefinitions` config key for leaner `the_schema_is` definitions (avoiding huge index descriptions in models).
|
7
|
+
|
8
|
+
## 2021-09-15 - 0.0.4
|
9
|
+
|
10
|
+
* Get rid of [Fast](https://jonatas.github.io/fast/) dependency. It is cool, but we switched to use Rubocop's own `NodePattern` to lessen the dependency burden (Fast was depending on [astrolabe](https://github.com/yujinakayama/astrolabe) which wasn't updated in 6 years, locking parser dependency to old version and making Fast incompatible with newer Rubocop);
|
11
|
+
* Introduce mandatory table name in the `the_schema_is` DSL (and the `WrongTableName` cop to check it);
|
12
|
+
* Internally, change cop classes to comply to newer (> 1.0) Rubocop API.
|
13
|
+
|
14
|
+
## 2020-05-07 - 0.0.3
|
15
|
+
|
16
|
+
First really working release.
|
data/README.md
CHANGED
@@ -1,9 +1,9 @@
|
|
1
1
|
# The schema is ...
|
2
2
|
|
3
3
|
[](http://badge.fury.io/rb/the_schema_is)
|
4
|
-
|
4
|
+

|
5
5
|
|
6
|
-
`the_schema_is` is a model schema annotation DSL
|
6
|
+
`the_schema_is` is a model schema annotation DSL for ActiveSupport models, enforced by Rubocop. [Jump to detailed description →](#so-how-your-approach-is-different).
|
7
7
|
|
8
8
|
### Why annotate?
|
9
9
|
|
@@ -34,10 +34,10 @@ Annotate gem provides a very powerful and configurable CLI/rake task which allow
|
|
34
34
|
|
35
35
|
It kinda achieves the goal, but in our experience, it also brings some problems:
|
36
36
|
|
37
|
-
* annotation regeneration is disruptive
|
38
|
-
* if on different developer's machines column order or defaults is different
|
39
|
-
* regeneration makes it hard to use schema annotation for commenting/explaining some fields: because regeneration will lose them, and because comments-between-comments will be hard to distinguish;
|
40
|
-
* the syntax of annotations is kinda ad-hoc
|
37
|
+
* annotation **regeneration is disruptive**, just replacing the whole block with a new one, which produces a lot of "false changes" (e.g. one field with a bit longer name was added → spacing of all fields were changed);
|
38
|
+
* if on different developer's machines **column order or defaults is different** in dev. DB, annotate also decides to rewrite all the annotations, sometimes adding tens files "changed" to PR;
|
39
|
+
* regeneration makes it **hard to use schema annotation for commenting/explaining** some fields: because regeneration will lose them, and because comments-between-comments will be hard to distinguish;
|
40
|
+
* the **syntax of annotations is kinda ad-hoc**, which makes it harder to add them by hand, so regeneration becomes the _only_ way to add them.
|
41
41
|
|
42
42
|
### So, how your approach is different?..
|
43
43
|
|
@@ -88,7 +88,7 @@ Using existing Rubocop's infrastructure brings several great benefits:
|
|
88
88
|
|
89
89
|
* you can include checking "if all annotations are actual" in your CI/pre-commit hooks easily;
|
90
90
|
* you can preview problems found, and then fix them automatically (with `rubocop -a`) or manually however you see suitable;
|
91
|
-
* the changes made with auto-
|
91
|
+
* the changes made with auto-correct is very local (just add/remove/change line related to relevant column), so your custom structuring, like separating groups of related columns with empty lines and comments, will be preserved;
|
92
92
|
* rubocop is easy to run on some sub-folder or one file, or files corresponding to some pattern; or exclude permanently for some file or folder.
|
93
93
|
|
94
94
|
### But what the block itself does?
|
@@ -110,11 +110,12 @@ The block isn't even evaluated at all (so potentially can contain any code, and
|
|
110
110
|
- the_schema_is/cops
|
111
111
|
```
|
112
112
|
3. Run `rubocop` and see what it now says about your models.
|
113
|
-
4. Now you can add schema definitions manually, or allow `rubocop --auto-
|
113
|
+
4. Now you can add schema definitions manually, or allow `rubocop --auto-correct` (or `-a`) to do its job! NB: you can always use `rubocop --auto-correct --only TheSchemaIs` to auto-correct ONLY this schema thing
|
114
114
|
|
115
115
|
To make reporting cleaner, all cops are split into:
|
116
116
|
|
117
117
|
* `Presence`
|
118
|
+
* `WrongTableName`
|
118
119
|
* `MissingColumn`
|
119
120
|
* `UnknownColumn`
|
120
121
|
* `WrongColumnDefinition`
|
@@ -133,8 +134,9 @@ TheSchemaIs:
|
|
133
134
|
Currently available settings are:
|
134
135
|
|
135
136
|
* `TablePrefix` to help `the_schema_is` deduce table name from class name;
|
136
|
-
* `Schema` to set path to schema (by default `db/schema.rb`)
|
137
|
-
* `BaseClass` to help `the_schema_is` guess what is a model class (by default `ApplicationRecord` and `ActiveRecord::Base`)
|
137
|
+
* `Schema` to set path to schema (by default `db/schema.rb`);
|
138
|
+
* `BaseClass` to help `the_schema_is` guess what is a model class (by default `ApplicationRecord` and `ActiveRecord::Base`);
|
139
|
+
* `RemoveDefinitions`: list of definition keys to remove (for example, `[index, foreign_key, limit]`) when copying definitions into models; this might be desirable for leaner `the_schema_is` statements, displaying only field types/names.
|
138
140
|
|
139
141
|
So, if you have your custom-named base class, you should do:
|
140
142
|
|
@@ -143,12 +145,12 @@ TheSchemaIs:
|
|
143
145
|
BaseClass: OurOwnBase
|
144
146
|
```
|
145
147
|
|
146
|
-
Note that Rubocop allows per-folder settings out of the box, which allows TheSchemaIs
|
148
|
+
Note that Rubocop allows per-folder settings out of the box, which allows TheSchemaIs to support complicated configurations with multiple databases and engines.
|
147
149
|
|
148
150
|
For example, consider your models are split into `app/models/users/` and `app/models/products` which are stored in the different databases, then you probably have different schemas and base classes for them. So, to configure it properly, you might want to do in `app/models/users/.rubocop.yml`:
|
149
151
|
|
150
152
|
```yaml
|
151
|
-
# Don't forget this for all other
|
153
|
+
# Don't forget this for all other cop settings to not be ignored
|
152
154
|
inherit_from: ../../../.rubocop.yml
|
153
155
|
|
154
156
|
TheSchemaIs:
|
@@ -158,13 +160,18 @@ TheSchemaIs:
|
|
158
160
|
|
159
161
|
## Some Q&A
|
160
162
|
|
161
|
-
* **Q: It doesn't check the actual DB?**
|
162
|
-
*
|
163
|
-
* **Q:
|
164
|
-
*
|
165
|
-
* **
|
166
|
-
*
|
167
|
-
|
163
|
+
* **Q: It doesn't check the actual DB?**
|
164
|
+
* A: No, it does not! At the current moment, our belief is that in a healthy Rails codebase `schema.rb` is always corresponding to DB state, so checking against it is enough. This approach makes the tooling much easier (with existing Rubocop's ecosystem of parsers/offenses/configurations).
|
165
|
+
* **Q: What if I don't use Rubocop?**
|
166
|
+
* A: You may want to try, at least? Do you know that you may disable or configure most of its checks to your liking? And auto-correct any code to your preferences?.. Or automatically create "TODO" config-file (which disables all the cops currently raising offenses, and allows to review them and later setup one-by-one)?.. It is much more than "linter making your code to complain about some rigid style guide".
|
167
|
+
* **Q: Cool, but I still don't want to.**
|
168
|
+
* A: ...OK, then you can disable all cops _except_ for `TheSchemaIs` namespace :)
|
169
|
+
* **How do I annotate my fabrics, model specs, routes, controllers, ... (which `annotate` allows)?**
|
170
|
+
* A: You don't. The same way you don't copy-paste the whole definition of the class into spec file which tests this class: Definition is in one place, tests and other code using this definition is another. DRY!
|
171
|
+
* **Rubocop is unhappy with the code `TheSchemaIs` generated**.
|
172
|
+
* A: There are two known things in generated `the_schema_is` blocks that Rubocop may complain about:
|
173
|
+
* Usage of double quotes for strings, if your config insists on single quotes: that's because we just copy code objects from `schema.rb`. Rubocop's auto-correct will fix it :) (Even in one run: "fixing TheSchemaIs, then fixing quotes");
|
174
|
+
* Too long blocks (if you have tables with dozens of columns, God forbid... as we do). It can be fixed by adding this to `.rubocop.yml`:
|
168
175
|
```yaml
|
169
176
|
Metrics/BlockLength:
|
170
177
|
ExcludedMethods:
|
data/config/defaults.yml
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
---
|
2
|
+
# For all TheSchemaIs cops the settings are the same.
|
3
|
+
# But note that Rubocop's behavior is slightly weird with --show-cops (it will not show that
|
4
|
+
# cop has those three setting)
|
5
|
+
TheSchemaIs:
|
6
|
+
Schema: db/schema.rb
|
7
|
+
BaseClass: ['ActiveRecord::Base', 'ApplicationRecord']
|
8
|
+
TablePrefix: ''
|
9
|
+
RemoveDefinitions: []
|
10
|
+
|
11
|
+
TheSchemaIs/Presence:
|
12
|
+
Description: "Check presence of the_schema_is statement in ActiveRecord models"
|
13
|
+
Enabled: true
|
14
|
+
|
15
|
+
TheSchemaIs/WrongTableName:
|
16
|
+
Description: "Check table name define by the_schema_is statement against one in schema.rb"
|
17
|
+
Enabled: true
|
18
|
+
|
19
|
+
TheSchemaIs/MissingColumn:
|
20
|
+
Description: "Check columns missing in the_schema_is definition (but present in schema.rb)"
|
21
|
+
Enabled: true
|
22
|
+
|
23
|
+
TheSchemaIs/UnknownColumn:
|
24
|
+
Description: "Check unknown column in the_schema_is definition (not defined in schema.rb)"
|
25
|
+
Enabled: true
|
26
|
+
|
27
|
+
TheSchemaIs/WrongColumnDefinition:
|
28
|
+
Description: "Check column definition in the_schema_is statement against schema.rb"
|
29
|
+
Enabled: true
|
@@ -1,15 +1,58 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'singleton'
|
4
|
+
|
3
5
|
module TheSchemaIs
|
4
6
|
module Cops
|
7
|
+
# This module mitigates usage of RuboCop's NodePattern in a more flexible manner. NodePattern
|
8
|
+
# (targeting RuboCop's goals) was only available as a metaprogramming macro, requiring to
|
9
|
+
# `def_node_search :some_method, pattern` before the pattern can be used, while we wanted to
|
10
|
+
# just do `ast.ast_search(some_pattern)`; so this method defines a method for each used pattern
|
11
|
+
# on the fly and hides this discrepancy. Used by NodeRefinements (mixed into parser's node) to
|
12
|
+
# provide `Node#ast_search` and `Node#ast_match`.
|
13
|
+
class Patterns
|
14
|
+
include Singleton
|
15
|
+
extend RuboCop::AST::NodePattern::Macros
|
16
|
+
|
17
|
+
class << self
|
18
|
+
extend Memoist
|
19
|
+
|
20
|
+
def search(pattern, node)
|
21
|
+
search_methods[pattern].then { |m| instance.send(m, node) }
|
22
|
+
end
|
23
|
+
|
24
|
+
def match(pattern, node)
|
25
|
+
match_methods[pattern].then { |m| instance.send(m, node) }
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
memoize def search_methods
|
31
|
+
Hash.new { |h, pattern|
|
32
|
+
method_name = "search_#{h.size}"
|
33
|
+
def_node_search method_name, pattern
|
34
|
+
h[pattern] = method_name
|
35
|
+
}
|
36
|
+
end
|
37
|
+
|
38
|
+
memoize def match_methods
|
39
|
+
Hash.new { |h, pattern|
|
40
|
+
method_name = "match_#{h.size}"
|
41
|
+
def_node_search method_name, pattern
|
42
|
+
h[pattern] = method_name
|
43
|
+
}
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
5
48
|
module NodeRefinements
|
6
49
|
refine ::Parser::AST::Node do
|
7
|
-
def
|
8
|
-
|
50
|
+
def ast_search(expr)
|
51
|
+
Patterns.search(expr, self).to_a
|
9
52
|
end
|
10
53
|
|
11
|
-
def
|
12
|
-
|
54
|
+
def ast_match(expr)
|
55
|
+
Patterns.match(expr, self).to_a.first
|
13
56
|
end
|
14
57
|
|
15
58
|
def arraify
|
@@ -23,7 +66,7 @@ module TheSchemaIs
|
|
23
66
|
end
|
24
67
|
|
25
68
|
def find_parent(type)
|
26
|
-
Enumerator.produce(parent, &:parent).
|
69
|
+
Enumerator.produce(parent, &:parent).find { |n| n && n.type == type }
|
27
70
|
end
|
28
71
|
end
|
29
72
|
end
|
@@ -15,66 +15,72 @@ module TheSchemaIs
|
|
15
15
|
float integer json string text time timestamp virtual].freeze
|
16
16
|
POSTGRES_COLUMN_TYPES = %i[jsonb inet cidr macaddr hstore uuid].freeze
|
17
17
|
|
18
|
-
COLUMN_DEFS = (STANDARD_COLUMN_TYPES + POSTGRES_COLUMN_TYPES + %i[column]).freeze
|
18
|
+
COLUMN_DEFS = (STANDARD_COLUMN_TYPES + POSTGRES_COLUMN_TYPES + %i[enum column]).freeze
|
19
19
|
|
20
|
-
Model = Struct.new(:class_name, :table_name, :source, :schema,
|
20
|
+
Model = Struct.new(:class_name, :table_name, :source, :schema, :table_name_node,
|
21
|
+
keyword_init: true)
|
21
22
|
|
22
23
|
Column = Struct.new(:name, :type, :definition, :source, keyword_init: true) do
|
23
24
|
def definition_source
|
24
25
|
return unless definition
|
25
26
|
|
26
|
-
eval('{' + definition.loc.expression.source + '}') # rubocop:disable Security/Eval
|
27
|
+
eval('{' + definition.loc.expression.source + '}') # rubocop:disable Security/Eval,Style/StringConcatenation
|
27
28
|
end
|
28
29
|
end
|
29
30
|
|
30
|
-
def self.
|
31
|
-
|
31
|
+
def self.parse(code)
|
32
|
+
# TODO: Some kind of "current version" (ask Rubocop!)
|
33
|
+
RuboCop::AST::ProcessedSource.new(code, 2.7).ast
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.schema(path, remove_definition_attrs: [])
|
37
|
+
ast = parse(File.read(path))
|
38
|
+
|
39
|
+
ast = remove_attributes(ast, remove_definition_attrs) unless remove_definition_attrs.empty?
|
32
40
|
|
33
|
-
|
34
|
-
'(block (send (const (const nil :ActiveRecord) :Schema) :define) _ $_)',
|
35
|
-
ast
|
36
|
-
).last.first
|
37
|
-
Fast.search('(block (send nil :create_table (str $_)) _ _)', content)
|
38
|
-
.each_slice(2).to_h { |t, name| [Array(name).first, t] } # FIXME: Why it sometimes makes arrays, and sometimes not?..
|
41
|
+
ast.ast_search('(block (send nil? :create_table (str $_) _) _ $_)').to_h
|
39
42
|
end
|
40
43
|
|
41
44
|
def self.model(ast, base_classes: %w[ActiveRecord::Base ApplicationRecord], table_prefix: nil)
|
42
45
|
base = base_classes_query(base_classes)
|
43
|
-
ast.
|
46
|
+
ast.ast_search("$(class $_ #{base} _)")
|
44
47
|
.map { |node, name| node2model(name, node, table_prefix.to_s) }
|
45
48
|
.compact
|
46
49
|
.first
|
47
50
|
end
|
48
51
|
|
49
|
-
def self.node2model(name_node, definition_node, table_prefix)
|
50
|
-
return if definition_node.
|
52
|
+
def self.node2model(name_node, definition_node, table_prefix) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
53
|
+
return if definition_node.ast_search('(send self :abstract_class= true)')&.any?
|
51
54
|
|
52
55
|
# If all children are classes/modules -- model is here only as a namespace, shouldn't be
|
53
56
|
# parsed/have the_schema_is
|
54
|
-
|
55
|
-
|
57
|
+
if definition_node.children&.dig(2)&.arraify&.all? { |n| %i[class module].include?(n.type) }
|
58
|
+
return
|
59
|
+
end
|
56
60
|
|
57
|
-
class_name = name_node.
|
61
|
+
class_name = name_node.loc.expression.source
|
58
62
|
|
59
|
-
|
63
|
+
schema_node, name_node =
|
64
|
+
definition_node.ast_search('$(block (send nil? :the_schema_is $_?) _ ...)')&.last
|
60
65
|
|
61
66
|
# TODO: https://api.rubyonrails.org/classes/ActiveRecord/ModelSchema/ClassMethods.html#method-i-table_name
|
62
67
|
# * consider table_prefix/table_suffix settings
|
63
68
|
# * also, consider engines!
|
64
|
-
table_name = definition_node.
|
69
|
+
table_name = definition_node.ast_search('(send self :table_name= (str $_))')&.last
|
65
70
|
|
66
71
|
Model.new(
|
67
72
|
class_name: class_name,
|
68
73
|
table_name: table_name ||
|
69
74
|
table_prefix.+(ActiveSupport::Inflector.tableize(class_name)),
|
70
75
|
source: definition_node,
|
71
|
-
schema:
|
76
|
+
schema: schema_node,
|
77
|
+
table_name_node: name_node&.first
|
72
78
|
)
|
73
79
|
end
|
74
80
|
|
75
81
|
def self.base_classes_query(classes)
|
76
82
|
classes
|
77
|
-
.map { |cls| cls.split('::').inject('nil') { |res, str| "(const #{res} :#{str})" } }
|
83
|
+
.map { |cls| cls.split('::').inject('nil?') { |res, str| "(const #{res} :#{str})" } }
|
78
84
|
.join(' ')
|
79
85
|
.then { |str| "{#{str}}" }
|
80
86
|
end
|
@@ -83,14 +89,37 @@ module TheSchemaIs
|
|
83
89
|
ast.arraify.map { |node|
|
84
90
|
# FIXME: Of course it should be easier to say "optional additional params"
|
85
91
|
if (type, name, defs =
|
86
|
-
node.
|
92
|
+
node.ast_match('(send {(send nil? :t) (lvar :t)} $_ (str $_) $_)'))
|
87
93
|
Column.new(name: name, type: type, definition: defs, source: node) \
|
88
94
|
if COLUMN_DEFS.include?(type)
|
89
|
-
elsif (type, name =
|
95
|
+
elsif (type, name = node.ast_match('(send {(send nil? :t) (lvar :t)} $_ (str $_))'))
|
90
96
|
Column.new(name: name, type: type, source: node) if COLUMN_DEFS.include?(type)
|
91
97
|
end
|
92
98
|
}.compact
|
93
99
|
end
|
100
|
+
|
101
|
+
# Removes unnecessary column definitions from further comparison, using schema source tree editing
|
102
|
+
def self.remove_attributes(ast, attrs_to_remove)
|
103
|
+
buf = ast.loc.expression.source_buffer
|
104
|
+
src = buf.source
|
105
|
+
rewriter = ::Parser::Source::TreeRewriter.new(buf)
|
106
|
+
|
107
|
+
# FIXME: Two nested cycles can be simplifid to just look for column definition, probably
|
108
|
+
ast.ast_search('(block (send nil? :create_table (str _) _) _ $_)').each do |table_def|
|
109
|
+
table_def.children.each do |col|
|
110
|
+
dfn = col.children[3] or next
|
111
|
+
dfn.children
|
112
|
+
.select { |c| attrs_to_remove.include?(c.children[0].children[0]) }
|
113
|
+
.each do |c|
|
114
|
+
prev_comma = c.source_range.begin_pos.step(by: -1).find { |pos| src[pos] == ',' }
|
115
|
+
range = ::Parser::Source::Range.new(buf, prev_comma, c.source_range.end_pos)
|
116
|
+
rewriter.remove(range)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
RuboCop::AST::ProcessedSource.new(rewriter.process, 2.7).ast
|
122
|
+
end
|
94
123
|
end
|
95
124
|
end
|
96
125
|
end
|
data/lib/the_schema_is/cops.rb
CHANGED
@@ -2,7 +2,6 @@
|
|
2
2
|
|
3
3
|
require 'memoist'
|
4
4
|
require 'rubocop'
|
5
|
-
require 'fast'
|
6
5
|
require 'backports/latest'
|
7
6
|
|
8
7
|
require_relative 'cops/node_util'
|
@@ -16,8 +15,12 @@ module TheSchemaIs
|
|
16
15
|
using Cops::NodeRefinements
|
17
16
|
|
18
17
|
module Cops
|
19
|
-
|
20
|
-
|
18
|
+
class << self
|
19
|
+
extend Memoist
|
20
|
+
|
21
|
+
memoize def fetch_schema(path, remove_definition_attrs: [])
|
22
|
+
Cops::Parser.schema(path, remove_definition_attrs: remove_definition_attrs)
|
23
|
+
end
|
21
24
|
end
|
22
25
|
end
|
23
26
|
|
@@ -25,9 +28,10 @@ module TheSchemaIs
|
|
25
28
|
extend Memoist
|
26
29
|
|
27
30
|
def self.included(cls)
|
28
|
-
cls.define_singleton_method(:badge)
|
31
|
+
cls.define_singleton_method(:badge) do
|
29
32
|
RuboCop::Cop::Badge.for("TheSchemaIs::#{name.split('::').last}")
|
30
|
-
|
33
|
+
end
|
34
|
+
super
|
31
35
|
end
|
32
36
|
|
33
37
|
def on_class(node)
|
@@ -35,14 +39,22 @@ module TheSchemaIs
|
|
35
39
|
base_classes: cop_config.fetch('BaseClass'),
|
36
40
|
table_prefix: cop_config['TablePrefix']) or return
|
37
41
|
|
38
|
-
|
42
|
+
register_offense(node)
|
43
|
+
end
|
44
|
+
|
45
|
+
# We need this method to tell Rubocop that EVEN if app/models/user.rb haven't changed, and
|
46
|
+
# .rubocop.yml haven't changed, we STILL may need to rerun the cop if schema.rb have changed.
|
47
|
+
def external_dependency_checksum
|
48
|
+
return unless schema_path
|
49
|
+
|
50
|
+
Digest::SHA1.hexdigest(File.read(schema_path))
|
39
51
|
end
|
40
52
|
|
41
53
|
private
|
42
54
|
|
43
55
|
attr_reader :model
|
44
56
|
|
45
|
-
def
|
57
|
+
def register_offense(node)
|
46
58
|
fail NotImplementedError
|
47
59
|
end
|
48
60
|
|
@@ -51,38 +63,40 @@ module TheSchemaIs
|
|
51
63
|
end
|
52
64
|
|
53
65
|
memoize def schema
|
54
|
-
|
66
|
+
attrs_to_remove = cop_config['RemoveDefinitions']&.map(&:to_sym) || []
|
67
|
+
# It is OK if it returns Nil, just will be handled by "schema is absent" cop
|
68
|
+
Cops.fetch_schema(schema_path, remove_definition_attrs: attrs_to_remove)[model.table_name]
|
55
69
|
end
|
56
70
|
|
57
71
|
memoize def model_columns
|
58
|
-
statements = model.schema.
|
72
|
+
statements = model.schema.ast_search('(block (send nil? :the_schema_is _?) _ $...)')
|
73
|
+
.last.last
|
59
74
|
|
60
75
|
Cops::Parser.columns(statements).to_h { |col| [col.name, col] }
|
61
76
|
end
|
62
77
|
|
63
78
|
memoize def schema_columns
|
64
|
-
|
65
|
-
statements = schema.ffast('(block (send nil :create_table) (args) $...)').last.last
|
66
|
-
|
67
|
-
Cops::Parser.columns(statements).to_h { |col| [col.name, col] }
|
79
|
+
Cops::Parser.columns(schema).to_h { |col| [col.name, col] }
|
68
80
|
end
|
69
81
|
end
|
70
82
|
|
71
|
-
class Presence < RuboCop::Cop::
|
83
|
+
class Presence < RuboCop::Cop::Base
|
72
84
|
include Common
|
85
|
+
extend RuboCop::Cop::AutoCorrector
|
73
86
|
|
74
87
|
MSG_NO_MODEL_SCHEMA = 'The schema is not specified in the model (use the_schema_is statement)'
|
75
88
|
MSG_NO_DB_SCHEMA = 'Table "%s" is not defined in %s'
|
76
89
|
|
77
|
-
|
78
|
-
return unless schema
|
90
|
+
private
|
79
91
|
|
80
|
-
|
92
|
+
def register_offense(node)
|
93
|
+
schema.nil? and
|
94
|
+
add_offense(model.source, message: MSG_NO_DB_SCHEMA % [model.table_name, schema_path])
|
81
95
|
|
82
|
-
|
96
|
+
model.schema.nil? and add_offense(model.source, message: MSG_NO_MODEL_SCHEMA) do |corrector|
|
83
97
|
indent = node.loc.expression.column + 2
|
84
98
|
code = [
|
85
|
-
"the_schema_is #{
|
99
|
+
"the_schema_is #{model.table_name.to_s.inspect} do |t|",
|
86
100
|
*schema_columns.map { |_, col| " #{col.source.loc.expression.source}" },
|
87
101
|
'end'
|
88
102
|
].map { |s| ' ' * indent + s }.join("\n").then { |s| "\n#{s}\n" }
|
@@ -91,52 +105,71 @@ module TheSchemaIs
|
|
91
105
|
corrector.insert_after(node.children[1].loc.expression, code)
|
92
106
|
end
|
93
107
|
end
|
108
|
+
end
|
109
|
+
|
110
|
+
class WrongTableName < RuboCop::Cop::Base
|
111
|
+
include Common
|
112
|
+
extend RuboCop::Cop::AutoCorrector
|
113
|
+
|
114
|
+
MSG_WRONG_TABLE_NAME = 'The real table name should be %p'
|
115
|
+
MSG_NO_TABLE_NAME = 'Table name is not specified'
|
94
116
|
|
95
117
|
private
|
96
118
|
|
97
|
-
def
|
98
|
-
schema.nil?
|
99
|
-
|
100
|
-
|
119
|
+
def register_offense(_node)
|
120
|
+
return if model.schema.nil? || schema.nil?
|
121
|
+
|
122
|
+
pp
|
123
|
+
|
124
|
+
if model.table_name_node.nil?
|
125
|
+
add_offense(model.schema, message: MSG_NO_TABLE_NAME) do |corrector|
|
126
|
+
corrector.insert_after(model.schema.children[0].loc.expression, " #{model.table_name.to_s.inspect}")
|
127
|
+
end
|
128
|
+
elsif model.table_name_node.children[0] != model.table_name
|
129
|
+
add_offense(model.table_name_node,
|
130
|
+
message: MSG_WRONG_TABLE_NAME % model.table_name) do |corrector|
|
131
|
+
corrector.replace(model.table_name_node.loc.expression, model.table_name.to_s.inspect)
|
132
|
+
end
|
133
|
+
end
|
101
134
|
end
|
102
135
|
end
|
103
136
|
|
104
|
-
class MissingColumn < RuboCop::Cop::
|
137
|
+
class MissingColumn < RuboCop::Cop::Base
|
105
138
|
include Common
|
139
|
+
extend RuboCop::Cop::AutoCorrector
|
106
140
|
|
107
141
|
MSG = 'Column "%s" definition is missing'
|
108
142
|
|
109
|
-
def autocorrect(_node)
|
110
|
-
lambda do |corrector|
|
111
|
-
missing_columns.each { |name, col|
|
112
|
-
prev_statement = model_columns
|
113
|
-
.slice(*schema_columns.keys[0...schema_columns.keys.index(name)])
|
114
|
-
.values.last&.source
|
115
|
-
if prev_statement
|
116
|
-
indent = prev_statement.loc.expression.column
|
117
|
-
corrector.insert_after(
|
118
|
-
prev_statement.loc.expression,
|
119
|
-
"\n#{' ' * indent}#{col.source.loc.expression.source}"
|
120
|
-
)
|
121
|
-
else
|
122
|
-
indent = model.schema.loc.expression.column + 2
|
123
|
-
corrector.insert_after(
|
124
|
-
# of "the_schema_is do |t|" -- children[1] is "|t|""
|
125
|
-
model.schema.children[1].loc.expression,
|
126
|
-
"\n#{' ' * indent}#{col.source.loc.expression.source}"
|
127
|
-
)
|
128
|
-
end
|
129
|
-
}
|
130
|
-
end
|
131
|
-
end
|
132
|
-
|
133
143
|
private
|
134
144
|
|
135
|
-
def
|
145
|
+
def register_offense(_node)
|
136
146
|
return if model.schema.nil? || schema.nil?
|
137
147
|
|
138
|
-
missing_columns.each do |
|
139
|
-
add_offense(model.schema, message: MSG % col.name)
|
148
|
+
missing_columns.each do |name, col|
|
149
|
+
add_offense(model.schema, message: MSG % col.name) do |corrector|
|
150
|
+
insert_column(corrector, name, col)
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def insert_column(corrector, name, col)
|
156
|
+
prev_statement = model_columns
|
157
|
+
.slice(*schema_columns.keys[0...schema_columns.keys.index(name)])
|
158
|
+
.values.last&.source
|
159
|
+
|
160
|
+
if prev_statement
|
161
|
+
indent = prev_statement.loc.expression.column
|
162
|
+
corrector.insert_after(
|
163
|
+
prev_statement.loc.expression,
|
164
|
+
"\n#{' ' * indent}#{col.source.loc.expression.source}"
|
165
|
+
)
|
166
|
+
else
|
167
|
+
indent = model.schema.loc.expression.column + 2
|
168
|
+
corrector.insert_after(
|
169
|
+
# of "the_schema_is do |t|" -- children[1] is "|t|""
|
170
|
+
model.schema.children[1].loc.expression,
|
171
|
+
"\n#{' ' * indent}#{col.source.loc.expression.source}"
|
172
|
+
)
|
140
173
|
end
|
141
174
|
end
|
142
175
|
|
@@ -145,14 +178,19 @@ module TheSchemaIs
|
|
145
178
|
end
|
146
179
|
end
|
147
180
|
|
148
|
-
class UnknownColumn < RuboCop::Cop::
|
181
|
+
class UnknownColumn < RuboCop::Cop::Base
|
149
182
|
include Common
|
183
|
+
extend RuboCop::Cop::AutoCorrector
|
150
184
|
|
151
185
|
MSG = 'Uknown column "%s"'
|
152
186
|
|
153
|
-
|
154
|
-
|
155
|
-
|
187
|
+
private
|
188
|
+
|
189
|
+
def register_offense(_node)
|
190
|
+
return if model.schema.nil? || schema.nil?
|
191
|
+
|
192
|
+
extra_columns.each do |_, col|
|
193
|
+
add_offense(col.source, message: MSG % col.name) do |corrector|
|
156
194
|
src_range = col.source.loc.expression
|
157
195
|
end_pos = col.source.next_sibling.then { |n|
|
158
196
|
n ? n.loc.expression.begin_pos - 2 : col.source.find_parent(:block).loc.end.begin_pos
|
@@ -164,42 +202,27 @@ module TheSchemaIs
|
|
164
202
|
end
|
165
203
|
end
|
166
204
|
|
167
|
-
private
|
168
|
-
|
169
|
-
def validate
|
170
|
-
return if model.schema.nil? || schema.nil?
|
171
|
-
|
172
|
-
extra_columns.each do |_, col|
|
173
|
-
add_offense(col.source, message: MSG % col.name)
|
174
|
-
end
|
175
|
-
end
|
176
|
-
|
177
205
|
def extra_columns
|
178
206
|
model_columns.reject { |name,| schema_columns.keys.include?(name) }
|
179
207
|
end
|
180
208
|
end
|
181
209
|
|
182
|
-
class WrongColumnDefinition < RuboCop::Cop::
|
210
|
+
class WrongColumnDefinition < RuboCop::Cop::Base
|
183
211
|
include Common
|
212
|
+
extend RuboCop::Cop::AutoCorrector
|
184
213
|
|
185
214
|
MSG = 'Wrong column definition: expected `%s`'
|
186
215
|
|
187
|
-
def autocorrect(_node)
|
188
|
-
lambda do |corrector|
|
189
|
-
wrong_columns.each do |mcol, scol|
|
190
|
-
corrector.replace(mcol.source.loc.expression, scol.source.loc.expression.source)
|
191
|
-
end
|
192
|
-
end
|
193
|
-
end
|
194
|
-
|
195
216
|
private
|
196
217
|
|
197
|
-
def
|
218
|
+
def register_offense(_node)
|
198
219
|
return if model.schema.nil? || schema.nil?
|
199
220
|
|
200
221
|
wrong_columns
|
201
222
|
.each do |mcol, scol|
|
202
|
-
add_offense(mcol.source, message: MSG % scol.source.loc.expression.source)
|
223
|
+
add_offense(mcol.source, message: MSG % scol.source.loc.expression.source) do |corrector|
|
224
|
+
corrector.replace(mcol.source.loc.expression, scol.source.loc.expression.source)
|
225
|
+
end
|
203
226
|
end
|
204
227
|
end
|
205
228
|
|
@@ -207,7 +230,9 @@ module TheSchemaIs
|
|
207
230
|
model_columns
|
208
231
|
.map { |name, col| [col, schema_columns[name]] }
|
209
232
|
.reject { |mcol, scol|
|
210
|
-
|
233
|
+
# When column is not in schema, we shouldn't try to check it: UnknownColumn cop will
|
234
|
+
# handle.
|
235
|
+
!scol || mcol.type == scol.type && mcol.definition_source == scol.definition_source
|
211
236
|
}
|
212
237
|
end
|
213
238
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: the_schema_is
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.5
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Victor Shepelev
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2021-11-04 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: backports
|
@@ -30,14 +30,14 @@ dependencies:
|
|
30
30
|
requirements:
|
31
31
|
- - ">="
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version:
|
33
|
+
version: 1.0.0
|
34
34
|
type: :runtime
|
35
35
|
prerelease: false
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
37
37
|
requirements:
|
38
38
|
- - ">="
|
39
39
|
- !ruby/object:Gem::Version
|
40
|
-
version:
|
40
|
+
version: 1.0.0
|
41
41
|
- !ruby/object:Gem::Dependency
|
42
42
|
name: activesupport
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
@@ -52,20 +52,6 @@ dependencies:
|
|
52
52
|
- - ">="
|
53
53
|
- !ruby/object:Gem::Version
|
54
54
|
version: '0'
|
55
|
-
- !ruby/object:Gem::Dependency
|
56
|
-
name: ffast
|
57
|
-
requirement: !ruby/object:Gem::Requirement
|
58
|
-
requirements:
|
59
|
-
- - ">="
|
60
|
-
- !ruby/object:Gem::Version
|
61
|
-
version: 0.1.8
|
62
|
-
type: :runtime
|
63
|
-
prerelease: false
|
64
|
-
version_requirements: !ruby/object:Gem::Requirement
|
65
|
-
requirements:
|
66
|
-
- - ">="
|
67
|
-
- !ruby/object:Gem::Version
|
68
|
-
version: 0.1.8
|
69
55
|
- !ruby/object:Gem::Dependency
|
70
56
|
name: memoist
|
71
57
|
requirement: !ruby/object:Gem::Requirement
|
@@ -130,8 +116,10 @@ executables: []
|
|
130
116
|
extensions: []
|
131
117
|
extra_rdoc_files: []
|
132
118
|
files:
|
119
|
+
- Changelog.md
|
133
120
|
- LICENSE.txt
|
134
121
|
- README.md
|
122
|
+
- config/defaults.yml
|
135
123
|
- lib/the_schema_is.rb
|
136
124
|
- lib/the_schema_is/cops.rb
|
137
125
|
- lib/the_schema_is/cops/inject.rb
|
@@ -149,14 +137,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
149
137
|
requirements:
|
150
138
|
- - ">="
|
151
139
|
- !ruby/object:Gem::Version
|
152
|
-
version: 2.
|
140
|
+
version: 2.5.0
|
153
141
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
154
142
|
requirements:
|
155
143
|
- - ">="
|
156
144
|
- !ruby/object:Gem::Version
|
157
145
|
version: '0'
|
158
146
|
requirements: []
|
159
|
-
rubygems_version: 3.
|
147
|
+
rubygems_version: 3.1.6
|
160
148
|
signing_key:
|
161
149
|
specification_version: 4
|
162
150
|
summary: ActiveRecord model annotations done right
|