yard-is-sequel 0.8.0

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.
data/README.md ADDED
@@ -0,0 +1,245 @@
1
+ # yard-is-sequel
2
+
3
+ YARD plugin for documenting Sequel ORM models.
4
+
5
+ ## Overview
6
+
7
+ `yard-is-sequel` is a YARD plugin that automatically documents Sequel ORM models by extracting information about database fields, associations, and model definitions directly from your Ruby code. It generates comprehensive documentation without requiring manual annotations for basic Sequel features.
8
+
9
+ ## Features
10
+
11
+ - **Automatic Field Documentation**: Extracts database column information from Sequel models
12
+ - **Association Detection**: Documents `many_to_one`, `one_to_many`, and `many_to_many` associations
13
+ - **Sequel Model Tagging**: Automatically identifies and tags classes that inherit from `Sequel::Model`
14
+ - **Migration Support**: Reads database schema from Sequel migrations
15
+ - **Integration with YARD**: Seamlessly integrates with existing YARD documentation workflows
16
+
17
+ ## Installation
18
+
19
+ ### As a Gem
20
+
21
+ Add to your `Gemfile`:
22
+
23
+ ```ruby
24
+ gem 'yard-is-sequel', '~> 0.8'
25
+ ```
26
+
27
+ Or install directly:
28
+
29
+ ```bash
30
+ gem install yard-is-sequel
31
+ ```
32
+
33
+ ### For Development
34
+
35
+ Add to your project's `.gemspec`:
36
+
37
+ ```ruby
38
+ spec.add_development_dependency 'yard-is-sequel', '~> 0.8'
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ### Basic Usage
44
+
45
+ Run YARD with the plugin enabled:
46
+
47
+ ```bash
48
+ $ yardoc --plugin is-sequel
49
+ ```
50
+
51
+ ### Using `.yardopts` File
52
+
53
+ Add the plugin to your `.yardopts` file:
54
+
55
+ ```
56
+ --plugin is-sequel
57
+ --markup markdown
58
+ --output-dir ./doc
59
+ ```
60
+
61
+ ### Environment Configuration
62
+
63
+ For field detection to work, you need to set the path to your Sequel migrations:
64
+
65
+ ```bash
66
+ export SEQUEL_MIGRATIONS_DIR=/path/to/your/migrations
67
+ ```
68
+
69
+ Or set it temporarily:
70
+
71
+ ```bash
72
+ SEQUEL_MIGRATIONS_DIR=/path/to/migrations yardoc --plugin is-sequel
73
+ ```
74
+
75
+ ## How It Works
76
+
77
+ ### Database Fields
78
+
79
+ The plugin processes tables defined via `set_dataset` or `dataset=`. It:
80
+ - Applies migrations to an in-memory SQLite database
81
+ - Extracts column names, types, and nullability constraints
82
+ - Documents each field as an attribute with proper type annotations
83
+
84
+ Example model:
85
+ ```ruby
86
+ class User < Sequel::Model
87
+ set_dataset :users
88
+ end
89
+ ```
90
+
91
+ Generated documentation will include all fields from the `users` table.
92
+
93
+ ### Associations
94
+
95
+ The plugin detects and documents Sequel associations:
96
+
97
+ ```ruby
98
+ class User < Sequel::Model
99
+ many_to_one :organization
100
+ one_to_many :posts
101
+ many_to_many :roles
102
+ end
103
+ ```
104
+
105
+ Each association is documented as an attribute with:
106
+ - Appropriate return types
107
+ - Association type tags
108
+ - Grouping under "Sequel Associations"
109
+
110
+ ### Model Detection
111
+
112
+ Classes inheriting from `Sequel::Model` are automatically tagged as Sequel models.
113
+
114
+ ## Configuration
115
+
116
+ ### Migration Directory
117
+
118
+ Set the environment variable for migrations:
119
+ ```bash
120
+ export SEQUEL_MIGRATIONS_DIR=./db/migrations
121
+ ```
122
+
123
+ ### Plugin Options
124
+
125
+ Currently, the plugin supports the following implicit configurations:
126
+ - Database fields are extracted only when `SEQUEL_MIGRATIONS_DIR` is set
127
+ - All Sequel models in processed files are documented
128
+ - Associations are automatically detected from method calls
129
+
130
+ ## Examples
131
+
132
+ ### Full Example Model
133
+
134
+ ```ruby
135
+ # app/models/user.rb
136
+ class User < Sequel::Model(:users)
137
+ # These will be documented automatically
138
+ many_to_one :organization
139
+ one_to_many :posts
140
+ many_to_many :permissions
141
+
142
+ # Custom methods are preserved
143
+ def full_name
144
+ "#{first_name} #{last_name}"
145
+ end
146
+ end
147
+ ```
148
+
149
+ ### Generated Documentation
150
+
151
+ The plugin generates documentation that includes:
152
+
153
+ 1. **Fields Section**: Database columns with types
154
+ 2. **Associations Section**: All Sequel associations
155
+ 3. **Methods Section**: Custom methods (preserved from existing YARD docs)
156
+
157
+ ## Development
158
+
159
+ ### Setting Up Development Environment
160
+
161
+ ```bash
162
+ git clone https://github.com/inat-get/yard-is-sequel.git
163
+ cd yard-is-sequel
164
+ bundle install
165
+ ```
166
+
167
+ ### Running Tests
168
+
169
+ ```bash
170
+ bundle exec rake spec
171
+ ```
172
+
173
+ ### Project Structure
174
+
175
+ ```
176
+ lib/
177
+ ├── yard-is-sequel.rb # Main plugin file
178
+ ├── yard-is-sequel/
179
+ │ ├── info.rb # Plugin metadata
180
+ │ ├── models.rb # Model detection handler
181
+ │ ├── associations.rb # Association handler
182
+ │ └── fields.rb # Field extraction handler
183
+ spec/ # Test files
184
+ ```
185
+
186
+ ### Contributing
187
+
188
+ 1. Fork the repository
189
+ 2. Create a feature branch
190
+ 3. Add tests for your changes
191
+ 4. Ensure all tests pass
192
+ 5. Submit a pull request
193
+
194
+ ## Requirements
195
+
196
+ - Ruby >= 3.4
197
+ - YARD >= 0.9
198
+ - Sequel >= 5.100
199
+ - SQLite3 >= 2.9 (for migration processing)
200
+
201
+ ## Limitations
202
+
203
+ - Field detection requires Sequel migrations
204
+ - Currently supports SQLite for schema extraction (but works with any DB via Sequel)
205
+ - Complex association options might not be fully parsed
206
+ - Database views are not currently supported
207
+
208
+ ## Troubleshooting
209
+
210
+ ### Fields Not Appearing
211
+ - Ensure `SEQUEL_MIGRATIONS_DIR` is set correctly
212
+ - Verify migrations can be applied without errors
213
+ - Check that your models use `set_dataset` or `dataset=`
214
+
215
+ ### Associations Not Documented
216
+ - Verify association method calls are at the class level
217
+ - Check for syntax errors in association definitions
218
+ - Ensure the plugin is loaded (check YARD output)
219
+
220
+ ### Plugin Not Loading
221
+ - Verify YARD version compatibility
222
+ - Check `.yardopts` file for correct plugin name
223
+ - Try running with `--debug` flag for more information
224
+
225
+ ## License
226
+
227
+ This project is licensed under the GPL-3.0-or-later License - see the [LICENSE](LICENSE) file for details.
228
+
229
+ ## Author
230
+
231
+ Ivan Shikhalev
232
+
233
+ ## Repository
234
+
235
+ https://github.com/inat-get/yard-is-sequel
236
+
237
+ ## Acknowledgments
238
+
239
+ - YARD team for the excellent documentation tool
240
+ - Sequel ORM maintainers
241
+ - Contributors and users of the plugin
242
+
243
+ ---
244
+
245
+ *Note: This plugin is designed to complement existing YARD documentation. It automatically adds Sequel-specific documentation but doesn't interfere with manually written documentation.*
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "info"
4
+
5
+ class IS::YARD::Sequel::AssociationsHandler < YARD::Handlers::Ruby::Base
6
+ handles method_call(:many_to_one)
7
+ handles method_call(:one_to_many)
8
+ handles method_call(:many_to_many)
9
+ namespace_only
10
+
11
+ def process
12
+ # Получаем имя ассоциации
13
+ first_param = statement.parameters.first
14
+ assoc_name = first_param.jump(:ident, :string_content).source.to_sym
15
+
16
+ # Определяем тип ассоциации
17
+ assoc_type = statement.method_name.source.to_sym
18
+
19
+ # Извлекаем опции
20
+ options = extract_options
21
+
22
+ # Определяем класс ассоциации
23
+ assoc_class = determine_association_class(assoc_name, options)
24
+
25
+ # Создаем атрибут в документации
26
+ register_attribute(assoc_name, assoc_type, assoc_class)
27
+ end
28
+
29
+ private
30
+
31
+ def extract_options
32
+ options = {}
33
+
34
+ # Ищем хэш опций в параметрах
35
+ statement.parameters.each do |param|
36
+ next unless param && param.respond_to?(:type)
37
+
38
+ if param.type == :hash || param.type == :assoclist || param.type == :list
39
+ process_hash_node(param, options)
40
+ elsif param.type == :bare_assoc_hash
41
+ process_bare_assoc_hash(param, options)
42
+ end
43
+ end
44
+
45
+ options
46
+ end
47
+
48
+ def process_hash_node(hash_node, options)
49
+ if hash_node.type == :hash
50
+ hash_node.children.each do |child|
51
+ next unless child.respond_to?(:type) && child.type == :assoc
52
+
53
+ key_node, value_node = child.children
54
+ process_assoc_pair(key_node, value_node, options) if key_node && value_node
55
+ end
56
+ elsif hash_node.type == :assoclist || hash_node.type == :list
57
+ hash_node.children.each do |child|
58
+ if child.respond_to?(:type) && child.type == :assoc && child.children.size >= 2
59
+ process_assoc_pair(child.children[0], child.children[1], options)
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ def process_bare_assoc_hash(hash_node, options)
66
+ hash_node.children.each do |child|
67
+ if child.respond_to?(:type) && child.type == :assoc && child.children.size >= 2
68
+ key_node, value_node = child.children
69
+ process_assoc_pair(key_node, value_node, options)
70
+ end
71
+ end
72
+ end
73
+
74
+ def process_assoc_pair(key_node, value_node, options)
75
+ return unless key_node && value_node
76
+
77
+ key_name = extract_key_name(key_node)
78
+ return unless key_name
79
+
80
+ options[key_name] = parse_option_value(value_node)
81
+ end
82
+
83
+ def extract_key_name(key_node)
84
+ return unless key_node.respond_to?(:source)
85
+
86
+ key_source = key_node.source
87
+ key_source = key_source.to_s
88
+ .gsub(/^:/, "")
89
+ .gsub(/:$/, "")
90
+ .gsub(/^["']|["']$/, "")
91
+
92
+ key_source.to_sym
93
+ end
94
+
95
+ def parse_option_value(value_node)
96
+ return nil unless value_node && value_node.respond_to?(:source)
97
+
98
+ source = value_node.source
99
+
100
+ case value_node.type
101
+ when :symbol_literal, :symbol
102
+ source = source.to_s.sub(/^:/, "")
103
+ when :string_literal
104
+ source = source.gsub(/^['"]|['"]$/, "")
105
+ when :dyna_symbol
106
+ source = source.to_s.sub(/^:/, "").gsub(/^["']|["']$/, "")
107
+ end
108
+
109
+ source
110
+ end
111
+
112
+ def determine_association_class(assoc_name, options)
113
+ class_name = options[:class] || options[:class_name]
114
+
115
+ if class_name
116
+ class_name = class_name.to_s
117
+ class_name = class_name.gsub(/^['"]|['"]$/, "")
118
+ return class_name
119
+ end
120
+
121
+ class_name = assoc_name.to_s
122
+ .sub(/s$/, "")
123
+ .split("_")
124
+ .map(&:capitalize)
125
+ .join
126
+
127
+ class_name
128
+ end
129
+
130
+ def register_attribute(name, assoc_type, assoc_class)
131
+ # Определяем тип атрибута: rw для many_to_one (read/write), r для остальных (read only)
132
+ # attr_type = (assoc_type == :many_to_one) ? "rw" : "r"
133
+
134
+ # Определяем возвращаемый тип
135
+ return_type = case assoc_type
136
+ when :many_to_one
137
+ "#{assoc_class}, nil"
138
+ when :one_to_many, :many_to_many
139
+ "Sequel::Dataset, Array<#{assoc_class}>"
140
+ end
141
+
142
+ # Создаем объект метода с директивой @!attribute
143
+ method_obj = YARD::CodeObjects::MethodObject.new(
144
+ namespace,
145
+ name,
146
+ :instance
147
+ )
148
+ method_obj.is_attribute = true
149
+ method_obj.group = 'Sequel Associations'
150
+ namespace.attributes[:instance][name] = {
151
+ read: method_obj,
152
+ write: method_obj
153
+ }
154
+
155
+ # Формируем документацию с директивой @!attribute
156
+ docstring_parts = []
157
+
158
+ # Директива @!attribute должна быть первой
159
+ # docstring_parts << "@!attribute [#{attr_type}]"
160
+ docstring_parts << "@return [#{return_type}]"
161
+
162
+ # Описание
163
+ docstring_parts << "Sequel #{assoc_type.to_s.gsub('_', '-')} association."
164
+
165
+ # Примеры
166
+ # example_content = generate_example(name, assoc_type, assoc_class)
167
+ # if example_content && !example_content.empty?
168
+ # docstring_parts << ""
169
+ # docstring_parts << "@example"
170
+ # example_content.lines.each do |line|
171
+ # docstring_parts << " #{line.chomp}"
172
+ # end
173
+ # end
174
+
175
+ method_obj.docstring = docstring_parts.join("\n")
176
+
177
+ # Добавляем тег для фильтрации
178
+ method_obj.add_tag(YARD::Tags::Tag.new(:sequel_association, assoc_type.to_s))
179
+
180
+ # Регистрируем в YARD
181
+ register(method_obj)
182
+
183
+ log.debug "[Sequel Plugin] Added association attribute: #{namespace}##{name} (#{assoc_type})"
184
+ end
185
+
186
+ def generate_example(name, assoc_type, assoc_class)
187
+ namespace_name = namespace.name.to_s
188
+ model_name = namespace_name.split("::").last.downcase
189
+
190
+ case assoc_type
191
+ when :many_to_one
192
+ <<~EXAMPLE.chomp
193
+ #{model_name}.#{name} # => #{assoc_class} instance or nil
194
+ #{model_name}.#{name} = #{assoc_class}.first
195
+ EXAMPLE
196
+ when :one_to_many
197
+ singular_name = name.to_s.chomp("s")
198
+ <<~EXAMPLE.chomp
199
+ #{model_name}.#{name} # => Dataset of #{assoc_class} objects
200
+ #{model_name}.add_#{singular_name}(#{assoc_class}.new)
201
+ EXAMPLE
202
+ when :many_to_many
203
+ singular_name = name.to_s.chomp("s")
204
+ <<~EXAMPLE.chomp
205
+ #{model_name}.#{name} # => Dataset of #{assoc_class} objects
206
+ #{model_name}.add_#{singular_name}(#{assoc_class}.first)
207
+ #{model_name}.#{name}_dataset # Many-to-many through join table
208
+ EXAMPLE
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sequel'
4
+
5
+ class IS::YARD::Sequel::FieldsHandler < YARD::Handlers::Ruby::Base
6
+
7
+ handles method_call(:set_dataset)
8
+ handles method_call(:dataset=)
9
+
10
+ def process
11
+ table_name = statement.parameters.first.jump(:symbol, :string).source.delete(':"\'')
12
+ database = self.class.database
13
+ return unless database
14
+ table_schema = database.schema(table_name)
15
+ return unless table_schema
16
+
17
+ table_schema.each do |column_name, info|
18
+ type = [ map_type(info[:db_type]) ]
19
+ type << 'nil' unless info[:allow_null] == false
20
+
21
+ object = YARD::CodeObjects::MethodObject.new(namespace, column_name) do |o|
22
+ o.source = statement.source
23
+ o.parameters = []
24
+ o.docstring = 'Sequel data field'
25
+ o.docstring.add_tag YARD::Tags::Tag::new(:return, '', type)
26
+ o.docstring.add_tag YARD::Tags::Tag::new(:sequel_field, '')
27
+ o.is_attribute = true
28
+ o.group = 'Sequel Fields'
29
+ end
30
+ register object
31
+ namespace.attributes[:instance][column_name] = {
32
+ read: object,
33
+ write: object
34
+ }
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def map_type source
41
+ case source.to_s.downcase
42
+ when 'integer'
43
+ 'Integer'
44
+ when 'float', 'double precision'
45
+ 'Float'
46
+ when /^char/, /^varchar/, 'text'
47
+ 'String'
48
+ when 'timestamp', 'datetime', 'time'
49
+ 'Time'
50
+ when 'boolean'
51
+ 'Boolean'
52
+ else
53
+ source
54
+ end
55
+ end
56
+
57
+ class << self
58
+
59
+ def database
60
+ @database ||= setup_database
61
+ end
62
+
63
+ private
64
+
65
+ def setup_database
66
+ # return unless defined?($SEQUEL_MIGRATIONS_DIR) && $SEQUEL_MIGRATIONS_DIR
67
+ begin
68
+ Sequel.extension :migration
69
+ db = Sequel.sqlite
70
+ Sequel::Migrator::run db, ENV['SEQUEL_MIGRATIONS_DIR']
71
+ db
72
+ rescue
73
+ nil
74
+ end
75
+ end
76
+
77
+ end
78
+
79
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IS; end
4
+ module IS::YARD; end
5
+ module IS::YARD::Sequel; end
6
+
7
+ module IS::YARD::Sequel::Info
8
+
9
+ NAME = 'yard-is-sequel'
10
+ VERSION = '0.8.0'
11
+ SUMMARY = 'YARD-plugin for Sequel-models documenting'
12
+ AUTHOR = 'Ivan Shikhalev'
13
+ LICENSE = 'GPL-3.0-or-later'
14
+ HOMEPAGE = 'https://github.com/inat-get/yard-is-sequel'
15
+
16
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # require "yard"
4
+
5
+ require_relative "info"
6
+
7
+ class IS::YARD::Sequel::ModelHandler < YARD::Handlers::Ruby::ClassHandler
8
+ handles :class
9
+
10
+ def process
11
+ # Проверяем, наследуется ли класс от Sequel::Model
12
+ return unless sequel_model?
13
+
14
+ # Добавляем тег к классу, что это Sequel модель
15
+ # Используем пустое значение для тега
16
+ namespace.add_tag(YARD::Tags::Tag.new(:sequel_model, ""))
17
+
18
+ log.debug "[Sequel Plugin] Detected Sequel model: #{namespace}"
19
+ end
20
+
21
+ private
22
+
23
+ def sequel_model?
24
+ # Проверяем наличие суперкласса
25
+ superclass = statement.superclass
26
+ return false unless superclass
27
+
28
+ # Получаем исходный код суперкласса как строку
29
+ superclass_source = superclass.source
30
+
31
+ # Проверяем разные варианты записи Sequel::Model:
32
+ # 1. class User < Sequel::Model
33
+ # 2. class User < Sequel::Model(:users)
34
+ # 3. class User < Sequel::Model(DB[:users])
35
+
36
+ # Простая проверка по подстроке
37
+ superclass_source =~ /Sequel::Model/
38
+ end
39
+
40
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "yard-is-sequel/info"
4
+
5
+ module IS::YARD::Sequel
6
+ def self.init
7
+ register_tags
8
+ end
9
+
10
+ def self.register_tags
11
+ YARD::Tags::Library.define_tag 'Sequel Model', :sequel_model
12
+ YARD::Tags::Library.define_tag "Sequel Field", :sequel_field
13
+ YARD::Tags::Library.define_tag 'Sequel Association', :sequel_association
14
+ end
15
+ end
16
+
17
+ require_relative 'yard-is-sequel/associations'
18
+ require_relative 'yard-is-sequel/models'
19
+ require_relative 'yard-is-sequel/fields'
20
+
21
+ YARD::Handlers::Processor.register_handler_namespace(:sequel, IS::YARD::Sequel)
22
+
23
+ IS::YARD::Sequel.init