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 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,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+
5
+ module TheSchemaIs
6
+ module DSL
7
+ # Just a no-op!
8
+ def the_schema_is(*); end
9
+ end
10
+ end
11
+
12
+ ActiveSupport.on_load(:active_record) do
13
+ extend TheSchemaIs::DSL
14
+ end
@@ -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: []