the_schema_is 0.0.1 → 0.0.5
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
[![Gem Version](https://badge.fury.io/rb/the_schema_is.svg)](http://badge.fury.io/rb/the_schema_is)
|
4
|
-
|
4
|
+
![Build Status](https://github.com/zverok/the_schema_is/workflows/CI/badge.svg?branch=master)
|
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
|