the_schema_is 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +176 -0
- data/lib/the_schema_is.rb +14 -0
- data/lib/the_schema_is/cops.rb +214 -0
- data/lib/the_schema_is/cops/inject.rb +20 -0
- data/lib/the_schema_is/cops/node_util.rb +31 -0
- data/lib/the_schema_is/cops/parser.rb +96 -0
- metadata +163 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 24d4d7e0d7d99a14a9d924af7efbbfbe4ee0fb9eef3b89537fb11378b4e5495e
|
4
|
+
data.tar.gz: e41ee7faf7ee3620dc655c3ec3468df3cd6d89a5eb93a6b130ceabad7554b99f
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: ce06ad394d6cec9c357af68eb370ec667707a7ea2c5df57c412a3b0e86d3122cc3865035f9464729b1548477aa23dc9350b5c7700f42d668cd72a90c487fdeeb
|
7
|
+
data.tar.gz: 745a19300e80a00177abad59d4c702a304b1eb1c6f5b94c2ed3eb3b63519c5438b4cdb762c752ac9b71ef8e8d665fb3180b729ab8fa72262131e11a287d84465
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2020 Victor 'Zverok' Shepelev
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,176 @@
|
|
1
|
+
# The schema is ...
|
2
|
+
|
3
|
+
[![Gem Version](https://badge.fury.io/rb/the_schema_is.svg)](http://badge.fury.io/rb/the_schema_is)
|
4
|
+
[![Build Status](https://travis-ci.org/zverok/the_schema_is.svg?branch=master)](https://travis-ci.org/zverok/the_schema_is)
|
5
|
+
|
6
|
+
`the_schema_is` is a model schema annotation DSL in ActiveSupport.
|
7
|
+
|
8
|
+
### Why annotate?
|
9
|
+
|
10
|
+
An important part of class' public interface is **what attributes objects of this class have**. In ActiveRecord, attributes are inferred from DB columns and only can be seen in `db/schema.rb`, which is unfortunate.
|
11
|
+
|
12
|
+
We believe it _should_ be part _immediately available_ information of class definition. "It is drawn automatically from DB" is kinda clever, but it _does not_ helps to read the code. "Auto-deduction from DB" could be used to compare actual table content's to the definition in Ruby but **not** to skip the definition.
|
13
|
+
|
14
|
+
> Fun fact: most of other languages' ORM have chosen "explictly list attributes in the model" approach, for some reason! For example, Python's [Django](https://docs.djangoproject.com/en/3.0/topics/db/models/#quick-example), Elixir's [Ecto](https://hexdocs.pm/phoenix/ecto.html#the-schema), Go's [Beego](https://beego.me/docs/mvc/model/overview.md#quickstart) and [Gorm](https://gorm.io/docs/#Quick-Start), Rust's [Diesel](https://github.com/diesel-rs/diesel/blob/v1.3.0/examples/postgres/getting_started_step_1/src/models.rs), most of popular [NodeJS's options](https://www.codediesel.com/javascript/nodejs-mysql-orms/), and PHP's [Symphony](https://symfony.com/doc/current/doctrine.html#creating-an-entity-class) (but, to be honest, not [Laravel](https://laravel.com/docs/6.x/eloquent#eloquent-model-conventions)).
|
15
|
+
|
16
|
+
### Well then, why not [annotate](https://github.com/ctran/annotate_models) gem?
|
17
|
+
|
18
|
+
Annotate gem provides a very powerful and configurable CLI/rake task which allows adding to your model (and factory/route/spec) files comment looking like...
|
19
|
+
|
20
|
+
```ruby
|
21
|
+
# == Schema Information
|
22
|
+
#
|
23
|
+
# Table name: users
|
24
|
+
#
|
25
|
+
# id :integer not null, primary key
|
26
|
+
# email :string default(""), not null
|
27
|
+
# encrypted_password :string default(""), not null
|
28
|
+
# last_sign_in_at :datetime
|
29
|
+
# last_sign_in_ip :inet
|
30
|
+
# created_at :datetime not null
|
31
|
+
# updated_at :datetime not null
|
32
|
+
# ....
|
33
|
+
```
|
34
|
+
|
35
|
+
It kinda achieves the goal, but in our experience, it also brings some problems:
|
36
|
+
|
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 on 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
|
+
|
42
|
+
### So, how your approach is different?..
|
43
|
+
|
44
|
+
`the_schema_is` allows you to do this:
|
45
|
+
|
46
|
+
```ruby
|
47
|
+
class User < ApplicationRecord
|
48
|
+
the_schema_is "users" do |t|
|
49
|
+
t.string "email", default: "", null: false
|
50
|
+
t.string "encrypted_password", null: false
|
51
|
+
t.datetime "last_sign_in_at"
|
52
|
+
t.inet "last_sign_in_ip"
|
53
|
+
t.datetime "created_at", null: false
|
54
|
+
t.datetime "updated_at", null: false
|
55
|
+
# ...
|
56
|
+
end
|
57
|
+
end
|
58
|
+
```
|
59
|
+
|
60
|
+
Idea is, it is _exactly_ the same DSL that `db/schema.rb` uses, so:
|
61
|
+
|
62
|
+
* it can be just copied from there (or written by hands in usual migration syntax);
|
63
|
+
* it is _code_, which can be supplemented with _comments_ explaining what some column does, or why the defaults are this way; it also can be structured with columns reordering and extra blank lines.
|
64
|
+
|
65
|
+
So, in reality, your annotation may look like this:
|
66
|
+
|
67
|
+
```ruby
|
68
|
+
class User < ApplicationRecord
|
69
|
+
the_schema_is "users" do |t|
|
70
|
+
t.string "email", default: "", null: false
|
71
|
+
# We use RSA encryption currently.
|
72
|
+
t.string "encrypted_password", null: false
|
73
|
+
|
74
|
+
t.inet "last_sign_in_ip" # FIXME: Legacy, we don't use it anymore because GDPR
|
75
|
+
|
76
|
+
t.datetime "last_sign_in_at"
|
77
|
+
|
78
|
+
t.datetime "created_at", null: false
|
79
|
+
t.datetime "updated_at", null: false
|
80
|
+
# ...
|
81
|
+
end
|
82
|
+
end
|
83
|
+
```
|
84
|
+
|
85
|
+
Now, `the_schema_is` gem consists of this DSL and _custom [Rubocop](https://www.rubocop.org/) cops_ which check the correspondence of this DSL in model classes to your `db/schema.rb` (and can automatically fix discrepancies found).
|
86
|
+
|
87
|
+
Using existing Rubocop's infrastructure brings several great benefits:
|
88
|
+
|
89
|
+
* you can include checking "if all annotations are actual" in your CI/pre-commit hooks easily;
|
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-fix 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
|
+
* 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
|
+
|
94
|
+
### But what the block itself does?
|
95
|
+
|
96
|
+
Nothing.
|
97
|
+
|
98
|
+
**Ugh... What?**
|
99
|
+
|
100
|
+
That's just how it is (at least for now) ¯\\\_(ツ)\_/¯
|
101
|
+
|
102
|
+
The block isn't even evaluated at all (so potentially can contain any code, and only Rubocop's cop will complain). In the future, it _can_ do some useful things (like, on app run in development environment compare scheme of the real DB with declarations in class), but for now, it is just noop declarative schema copy-paste.
|
103
|
+
|
104
|
+
## Usage
|
105
|
+
|
106
|
+
1. Add to your Gemfile `gem 'the_schema_is'` and run `bundle install`.
|
107
|
+
2. Add to your `.rubocop.yml` this:
|
108
|
+
```yaml
|
109
|
+
require:
|
110
|
+
- the_schema_is/cops
|
111
|
+
```
|
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-fix` (or `-a`) to do its job! NB: you can always use `rubocop --auto-fix --only TheSchemaIs` to auto-fix ONLY this schema thing
|
114
|
+
|
115
|
+
To make reporting cleaner, all cops are split into:
|
116
|
+
|
117
|
+
* `Presence`
|
118
|
+
* `MissingColumn`
|
119
|
+
* `UnknownColumn`
|
120
|
+
* `WrongColumnDefinition`
|
121
|
+
|
122
|
+
It is not advisable to selectively turn them off, but you may know better (for example, some may experiment with leaving in models just `t.<type> '<name>'` without details about defaults and limit, and therefore turn off `WrongColumnDefinition`), all of it is pretty experimental!
|
123
|
+
|
124
|
+
## Setting
|
125
|
+
|
126
|
+
`the_schema_is` cops support some configuration, which should be done on the namespace level in your `.rubocop.yml`, for example:
|
127
|
+
|
128
|
+
```yaml
|
129
|
+
TheSchemaIs:
|
130
|
+
Schema: db/other-schema-file.rb
|
131
|
+
```
|
132
|
+
|
133
|
+
Currently available settings are:
|
134
|
+
|
135
|
+
* `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`).
|
138
|
+
|
139
|
+
So, if you have your custom-named base class, you should do:
|
140
|
+
|
141
|
+
```yaml
|
142
|
+
TheSchemaIs:
|
143
|
+
BaseClass: OurOwnBase
|
144
|
+
```
|
145
|
+
|
146
|
+
Note that Rubocop allows per-folder settings out of the box, which allows TheSchemaIs even at the tender version of 0.0.1, support complicated configurations with multiple databases and engines.
|
147
|
+
|
148
|
+
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
|
+
|
150
|
+
```yaml
|
151
|
+
# Don't forget this for all other cops to not be ignored
|
152
|
+
inherit_from: ../../../.rubocop.yml
|
153
|
+
|
154
|
+
TheSchemaIs:
|
155
|
+
BaseClass: Users::BaseRecord
|
156
|
+
Schema: db/users_schema.rb
|
157
|
+
```
|
158
|
+
|
159
|
+
## Some Q&A
|
160
|
+
|
161
|
+
* **Q: It doesn't check the actual DB?** 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).
|
162
|
+
* **Q: What if I don't use Rubocop?** 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".
|
163
|
+
* **Q: Cool, but I still don't want to.** ...OK, then you can disable all cops _except_ for `TheSchemaIs` namespace :)
|
164
|
+
* **How do I annotate my fabrics, model specs, routes, controllers, ... (which `annotate` allows)?** 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!
|
165
|
+
* **Rubocop is unhappy with the code `TheSchemaIs` generated**. There are two known things in generated `the_schema_is` blocks that Rubocop may complain about:
|
166
|
+
* 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-fix will fix it :) (Even in one run: "fixing TheSchemaIs, then fixing quotes");
|
167
|
+
* 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
|
+
```yaml
|
169
|
+
Metrics/BlockLength:
|
170
|
+
ExcludedMethods:
|
171
|
+
- the_schema_is
|
172
|
+
```
|
173
|
+
|
174
|
+
## Author and License
|
175
|
+
|
176
|
+
[Victor Shepelev aka "zverok"](https://zverok.github.io), MIT.
|
@@ -0,0 +1,214 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'memoist'
|
4
|
+
require 'rubocop'
|
5
|
+
require 'fast'
|
6
|
+
require 'backports/latest'
|
7
|
+
|
8
|
+
require_relative 'cops/node_util'
|
9
|
+
require_relative 'cops/inject'
|
10
|
+
|
11
|
+
TheSchemaIs::Cops::Inject.defaults!
|
12
|
+
|
13
|
+
require_relative 'cops/parser'
|
14
|
+
|
15
|
+
module TheSchemaIs
|
16
|
+
using Cops::NodeRefinements
|
17
|
+
|
18
|
+
module Cops
|
19
|
+
def self.schema_cache
|
20
|
+
@schema_cache ||= Hash.new { |h, path| h[path] = Cops::Parser.schema(path) }
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
module Common
|
25
|
+
extend Memoist
|
26
|
+
|
27
|
+
def self.included(cls)
|
28
|
+
cls.define_singleton_method(:badge) {
|
29
|
+
RuboCop::Cop::Badge.for("TheSchemaIs::#{name.split('::').last}")
|
30
|
+
}
|
31
|
+
end
|
32
|
+
|
33
|
+
def on_class(node)
|
34
|
+
@model = Cops::Parser.model(node,
|
35
|
+
base_classes: cop_config.fetch('BaseClass'),
|
36
|
+
table_prefix: cop_config['TablePrefix']) or return
|
37
|
+
|
38
|
+
validate
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
attr_reader :model
|
44
|
+
|
45
|
+
def validate
|
46
|
+
fail NotImplementedError
|
47
|
+
end
|
48
|
+
|
49
|
+
memoize def schema_path
|
50
|
+
cop_config.fetch('Schema')
|
51
|
+
end
|
52
|
+
|
53
|
+
memoize def schema
|
54
|
+
Cops.schema_cache.dig(schema_path, model.table_name)
|
55
|
+
end
|
56
|
+
|
57
|
+
memoize def model_columns
|
58
|
+
statements = model.schema.ffast('(block (send nil :the_schema_is) (args) $...)').last.last
|
59
|
+
|
60
|
+
Cops::Parser.columns(statements).to_h { |col| [col.name, col] }
|
61
|
+
end
|
62
|
+
|
63
|
+
memoize def schema_columns
|
64
|
+
# FIXME: should be already done in Parser.schema, probably!
|
65
|
+
statements = schema.ffast('(block (send nil :create_table) (args) $...)').last.last
|
66
|
+
|
67
|
+
Cops::Parser.columns(statements).to_h { |col| [col.name, col] }
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
class Presence < RuboCop::Cop::Cop
|
72
|
+
include Common
|
73
|
+
|
74
|
+
MSG_NO_MODEL_SCHEMA = 'The schema is not specified in the model (use the_schema_is statement)'
|
75
|
+
MSG_NO_DB_SCHEMA = 'Table "%s" is not defined in %s'
|
76
|
+
|
77
|
+
def autocorrect(node)
|
78
|
+
return unless schema
|
79
|
+
|
80
|
+
m = model
|
81
|
+
|
82
|
+
lambda do |corrector|
|
83
|
+
indent = node.loc.expression.column + 2
|
84
|
+
code = [
|
85
|
+
"the_schema_is #{m.table_name.to_s.inspect} do |t|",
|
86
|
+
*schema_columns.map { |_, col| " #{col.source.loc.expression.source}" },
|
87
|
+
'end'
|
88
|
+
].map { |s| ' ' * indent + s }.join("\n").then { |s| "\n#{s}\n" }
|
89
|
+
|
90
|
+
# in "class User < ActiveRecord::Base" -- second child is "ActiveRecord::Base"
|
91
|
+
corrector.insert_after(node.children[1].loc.expression, code)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
private
|
96
|
+
|
97
|
+
def validate
|
98
|
+
schema.nil? and
|
99
|
+
add_offense(model.source, message: MSG_NO_DB_SCHEMA % [model.table_name, schema_path])
|
100
|
+
model.schema.nil? and add_offense(model.source, message: MSG_NO_MODEL_SCHEMA)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
class MissingColumn < RuboCop::Cop::Cop
|
105
|
+
include Common
|
106
|
+
|
107
|
+
MSG = 'Column "%s" definition is missing'
|
108
|
+
|
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
|
+
private
|
134
|
+
|
135
|
+
def validate
|
136
|
+
return if model.schema.nil? || schema.nil?
|
137
|
+
|
138
|
+
missing_columns.each do |_, col|
|
139
|
+
add_offense(model.schema, message: MSG % col.name)
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
def missing_columns
|
144
|
+
schema_columns.reject { |name,| model_columns.keys.include?(name) }
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
class UnknownColumn < RuboCop::Cop::Cop
|
149
|
+
include Common
|
150
|
+
|
151
|
+
MSG = 'Uknown column "%s"'
|
152
|
+
|
153
|
+
def autocorrect(_node)
|
154
|
+
lambda do |corrector|
|
155
|
+
extra_columns.each do |_, col|
|
156
|
+
src_range = col.source.loc.expression
|
157
|
+
end_pos = col.source.next_sibling.then { |n|
|
158
|
+
n ? n.loc.expression.begin_pos - 2 : col.source.find_parent(:block).loc.end.begin_pos
|
159
|
+
}
|
160
|
+
range =
|
161
|
+
::Parser::Source::Range.new(src_range.source_buffer, src_range.begin_pos - 2, end_pos)
|
162
|
+
corrector.remove(range)
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
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
|
+
def extra_columns
|
178
|
+
model_columns.reject { |name,| schema_columns.keys.include?(name) }
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
class WrongColumnDefinition < RuboCop::Cop::Cop
|
183
|
+
include Common
|
184
|
+
|
185
|
+
MSG = 'Wrong column definition: expected `%s`'
|
186
|
+
|
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
|
+
private
|
196
|
+
|
197
|
+
def validate
|
198
|
+
return if model.schema.nil? || schema.nil?
|
199
|
+
|
200
|
+
wrong_columns
|
201
|
+
.each do |mcol, scol|
|
202
|
+
add_offense(mcol.source, message: MSG % scol.source.loc.expression.source)
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
def wrong_columns
|
207
|
+
model_columns
|
208
|
+
.map { |name, col| [col, schema_columns[name]] }
|
209
|
+
.reject { |mcol, scol|
|
210
|
+
mcol.type == scol.type && mcol.definition_source == scol.definition_source
|
211
|
+
}
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# copy-paste from https://github.com/rubocop-hq/rubocop-rspec/blob/master/lib/rubocop/rspec/inject.rb
|
4
|
+
# ...and other projects in rubocop-hq :shrug:
|
5
|
+
module TheSchemaIs
|
6
|
+
module Cops
|
7
|
+
# Because RuboCop doesn't yet support plugins, we have to monkey patch in a
|
8
|
+
# bit of our configuration.
|
9
|
+
module Inject
|
10
|
+
def self.defaults!
|
11
|
+
path = File.expand_path('../../../config/defaults.yml', __dir__)
|
12
|
+
hash = RuboCop::ConfigLoader.send(:load_yaml_configuration, path)
|
13
|
+
config = RuboCop::Config.new(hash, path)
|
14
|
+
puts "configuration from #{path}" if RuboCop::ConfigLoader.debug?
|
15
|
+
config = RuboCop::ConfigLoader.merge_with_default(config, path)
|
16
|
+
RuboCop::ConfigLoader.instance_variable_set(:@default_configuration, config)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TheSchemaIs
|
4
|
+
module Cops
|
5
|
+
module NodeRefinements
|
6
|
+
refine ::Parser::AST::Node do
|
7
|
+
def ffast(expr)
|
8
|
+
Fast.search(expr, self)
|
9
|
+
end
|
10
|
+
|
11
|
+
def ffast_match?(expr)
|
12
|
+
Fast.match?(expr, self)
|
13
|
+
end
|
14
|
+
|
15
|
+
def arraify
|
16
|
+
type == :begin ? children : [self]
|
17
|
+
end
|
18
|
+
|
19
|
+
def next_sibling
|
20
|
+
return unless parent
|
21
|
+
|
22
|
+
parent.children.index(self).then { |i| parent.children[i + 1] }
|
23
|
+
end
|
24
|
+
|
25
|
+
def find_parent(type)
|
26
|
+
Enumerator.produce(parent, &:parent).slice_after { |n| n && n.type == type }.first.last
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/inflector'
|
4
|
+
|
5
|
+
module TheSchemaIs
|
6
|
+
module Cops
|
7
|
+
module Parser
|
8
|
+
using NodeRefinements
|
9
|
+
|
10
|
+
# See https://github.com/rails/rails/blob/f33d52c95217212cbacc8d5e44b5a8e3cdc6f5b3/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb#L217
|
11
|
+
# TODO: numeric is just an alias for decimal
|
12
|
+
# TODO: different adapters can add another types
|
13
|
+
# https://edgeguides.rubyonrails.org/active_record_postgresql.html
|
14
|
+
STANDARD_COLUMN_TYPES = %i[bigint binary boolean date datetime decimal numeric
|
15
|
+
float integer json string text time timestamp virtual].freeze
|
16
|
+
POSTGRES_COLUMN_TYPES = %i[jsonb inet cidr macaddr hstore uuid].freeze
|
17
|
+
|
18
|
+
COLUMN_DEFS = (STANDARD_COLUMN_TYPES + POSTGRES_COLUMN_TYPES + %i[column]).freeze
|
19
|
+
|
20
|
+
Model = Struct.new(:class_name, :table_name, :source, :schema, keyword_init: true)
|
21
|
+
|
22
|
+
Column = Struct.new(:name, :type, :definition, :source, keyword_init: true) do
|
23
|
+
def definition_source
|
24
|
+
return unless definition
|
25
|
+
|
26
|
+
eval('{' + definition.loc.expression.source + '}') # rubocop:disable Security/Eval
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.schema(path)
|
31
|
+
ast = Fast.ast(File.read(path))
|
32
|
+
|
33
|
+
content = Fast.search(
|
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?..
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.model(ast, base_classes: %w[ActiveRecord::Base ApplicationRecord], table_prefix: nil)
|
42
|
+
base = base_classes_query(base_classes)
|
43
|
+
ast.ffast("(class $_ #{base})").each_slice(2)
|
44
|
+
.map { |node, name| node2model(name, node, table_prefix.to_s) }
|
45
|
+
.compact
|
46
|
+
.first
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.node2model(name_node, definition_node, table_prefix)
|
50
|
+
return if definition_node.ffast('(send self abstract_class= true)').any?
|
51
|
+
|
52
|
+
# If all children are classes/modules -- model is here only as a namespace, shouldn't be
|
53
|
+
# parsed/have the_schema_is
|
54
|
+
return if definition_node
|
55
|
+
.children[2]&.arraify&.all? { |n| %i[class module].include?(n.type) }
|
56
|
+
|
57
|
+
class_name = name_node.first.loc.expression.source
|
58
|
+
|
59
|
+
schema = definition_node.ffast('$(block (send nil :the_schema_is) _ ...')&.last
|
60
|
+
|
61
|
+
# TODO: https://api.rubyonrails.org/classes/ActiveRecord/ModelSchema/ClassMethods.html#method-i-table_name
|
62
|
+
# * consider table_prefix/table_suffix settings
|
63
|
+
# * also, consider engines!
|
64
|
+
table_name = definition_node.ffast('(send self table_name= (str $_)')&.last
|
65
|
+
|
66
|
+
Model.new(
|
67
|
+
class_name: class_name,
|
68
|
+
table_name: table_name ||
|
69
|
+
table_prefix.+(ActiveSupport::Inflector.tableize(class_name)),
|
70
|
+
source: definition_node,
|
71
|
+
schema: schema
|
72
|
+
)
|
73
|
+
end
|
74
|
+
|
75
|
+
def self.base_classes_query(classes)
|
76
|
+
classes
|
77
|
+
.map { |cls| cls.split('::').inject('nil') { |res, str| "(const #{res} :#{str})" } }
|
78
|
+
.join(' ')
|
79
|
+
.then { |str| "{#{str}}" }
|
80
|
+
end
|
81
|
+
|
82
|
+
def self.columns(ast)
|
83
|
+
ast.arraify.map { |node|
|
84
|
+
# FIXME: Of course it should be easier to say "optional additional params"
|
85
|
+
if (type, name, defs =
|
86
|
+
node.ffast_match?('(send {(send nil t) (lvar t)} $_ (str $_) $...'))
|
87
|
+
Column.new(name: name, type: type, definition: defs, source: node) \
|
88
|
+
if COLUMN_DEFS.include?(type)
|
89
|
+
elsif (type, name = Fast.match?('(send {(send nil t) (lvar t)} $_ (str $_)', node))
|
90
|
+
Column.new(name: name, type: type, source: node) if COLUMN_DEFS.include?(type)
|
91
|
+
end
|
92
|
+
}.compact
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
metadata
ADDED
@@ -0,0 +1,163 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: the_schema_is
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Victor Shepelev
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2020-04-22 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: backports
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 3.16.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 3.16.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rubocop
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: activesupport
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
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
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: memoist
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rubocop-rspec
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: rake
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: rubygems-tasks
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
description: |2
|
126
|
+
Annotating ActiveRecord models with lists of columns defined through DSL and checked with
|
127
|
+
custom Rubocop's cop.
|
128
|
+
email: zverok.offline@gmail.com
|
129
|
+
executables: []
|
130
|
+
extensions: []
|
131
|
+
extra_rdoc_files: []
|
132
|
+
files:
|
133
|
+
- LICENSE.txt
|
134
|
+
- README.md
|
135
|
+
- lib/the_schema_is.rb
|
136
|
+
- lib/the_schema_is/cops.rb
|
137
|
+
- lib/the_schema_is/cops/inject.rb
|
138
|
+
- lib/the_schema_is/cops/node_util.rb
|
139
|
+
- lib/the_schema_is/cops/parser.rb
|
140
|
+
homepage: https://github.com/zverok/the_schema_is
|
141
|
+
licenses:
|
142
|
+
- MIT
|
143
|
+
metadata: {}
|
144
|
+
post_install_message:
|
145
|
+
rdoc_options: []
|
146
|
+
require_paths:
|
147
|
+
- lib
|
148
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - ">="
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: 2.6.0
|
153
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
154
|
+
requirements:
|
155
|
+
- - ">="
|
156
|
+
- !ruby/object:Gem::Version
|
157
|
+
version: '0'
|
158
|
+
requirements: []
|
159
|
+
rubygems_version: 3.0.3
|
160
|
+
signing_key:
|
161
|
+
specification_version: 4
|
162
|
+
summary: ActiveRecord model annotations done right
|
163
|
+
test_files: []
|