sidenotes 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3d148544eafc19e2884ef9cd774c8d65c09ba29c6bce5a2cfaf1c139dc6eee4b
4
+ data.tar.gz: 583fd447f4e4469d32ab5179190a4be72d6f57a990379ef504b81462907b9061
5
+ SHA512:
6
+ metadata.gz: fb0d4ad4bbc6faf74a9cc2097d777f2b0e41ba03b2843eb266bc709c1c14f9dba62d5d9017147aa6b9b1c96fcb5be17f98b810f3522bda98abd05dd0d95c92c4
7
+ data.tar.gz: ef5bebedb9a3536e92729859d7fb65778540c27813fc8b071f0a20ba6f030e3d25c835556a54c82b48696521c3090416a704e15a914e43e6e9132c4f4b1c9bb2
data/.rubocop.yml ADDED
@@ -0,0 +1,22 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.1
3
+ NewCops: enable
4
+ SuggestExtensions: false
5
+ Exclude:
6
+ - "spec/**/*"
7
+ - "vendor/**/*"
8
+
9
+ Style/Documentation:
10
+ Enabled: false
11
+
12
+ Metrics/MethodLength:
13
+ Max: 25
14
+
15
+ Metrics/AbcSize:
16
+ Max: 30
17
+
18
+ Metrics/ClassLength:
19
+ Max: 150
20
+
21
+ Layout/LineLength:
22
+ Max: 120
data/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-04-08
9
+
10
+ ### Added
11
+
12
+ - Initial release
13
+ - YAML and JSON annotation output formats
14
+ - Column inspection: name, type, default, nullable, limit, precision, scale, comment
15
+ - Index inspection: name, columns, unique, where, using
16
+ - Association inspection: type, name, class_name, foreign_key, polymorphic, through
17
+ - Foreign key and check constraint inspection
18
+ - Model metadata: table name, primary key, STI column, enums, encrypted attributes
19
+ - Support for STI, polymorphic, HABTM, namespaced, and self-referential models
20
+ - Configurable sections, output directory, and exclusion patterns
21
+ - Rake tasks: `sidenotes:generate`, `sidenotes:clean`, `sidenotes:model`
22
+ - Rails generator: `rails generate sidenotes:install`
23
+ - Ruby 3.0+ and Rails 6.1+ support
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Wes Mason
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,206 @@
1
+ # Sidenotes
2
+
3
+ [![CI](https://github.com/1stvamp/sidenotes-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/1stvamp/sidenotes-ruby/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/sidenotes.svg)](https://badge.fury.io/rb/sidenotes)
5
+
6
+ Structured YAML/JSON schema annotations for Rails models, as sidecar files, not inline comments.
7
+
8
+ Sidenotes generates metadata files alongside your Rails models, giving you rich schema information without cluttering your source files. IDEs, editors, and tooling can consume these files to surface column types, indexes, associations, and more.
9
+
10
+ ## Why Sidenotes over annotate?
11
+
12
+ | | annotate | sidenotes |
13
+ |---|---|---|
14
+ | Output | Inline comments in model files | Separate sidecar files |
15
+ | Git noise | Every schema change touches model files | Annotation files **can be gitignored** |
16
+ | Format | Plain text | Structured YAML/JSON |
17
+ | Machine-readable | No | Yes; IDEs and tools can parse it |
18
+ | Merge conflicts | Frequent on model files | None, annotations are local |
19
+ | Customizable sections | Limited | Columns, indexes, associations, FKs, constraints, metadata |
20
+
21
+ ## Installation
22
+
23
+ Add to your Gemfile's development group:
24
+
25
+ ```ruby
26
+ group :development do
27
+ gem "sidenotes"
28
+ end
29
+ ```
30
+
31
+ Then run `bundle install` and generate annotations:
32
+
33
+ ```bash
34
+ bundle install
35
+ rake sidenotes:generate
36
+ ```
37
+
38
+ That's it. Sidenotes works with sensible defaults and no configuration.
39
+
40
+ ### Optional setup
41
+
42
+ If you want to customise the configuration, run the install generator:
43
+
44
+ ```bash
45
+ rails generate sidenotes:install
46
+ ```
47
+
48
+ This creates `config/initializers/sidenotes.rb` (guarded with `return unless defined?(Sidenotes)` so it's safe in production) and adds `.annotations/` to your `.gitignore`.
49
+
50
+ If you'd prefer to commit your annotations, skip the gitignore step:
51
+
52
+ ```bash
53
+ rails generate sidenotes:install --no-gitignore
54
+ ```
55
+
56
+ ## Usage
57
+
58
+ ### Generate annotations for all models
59
+
60
+ ```bash
61
+ rake sidenotes:generate
62
+ ```
63
+
64
+ ### Generate for a single model
65
+
66
+ ```bash
67
+ rake sidenotes:model MODEL=User
68
+ ```
69
+
70
+ ### Remove all annotation files
71
+
72
+ ```bash
73
+ rake sidenotes:clean
74
+ ```
75
+
76
+ ### Example output
77
+
78
+ Running `rake sidenotes:generate` creates `.annotations/user.yml`:
79
+
80
+ ```yaml
81
+ # Generated by Sidenotes v0.1.0 on 2026-04-08 12:00:00 UTC
82
+ # Do not edit manually - regenerate with `rake sidenotes:generate`
83
+ ---
84
+ User:
85
+ metadata:
86
+ table_name: users
87
+ primary_key: id
88
+ enums:
89
+ role:
90
+ - member
91
+ - admin
92
+ - moderator
93
+ columns:
94
+ - name: id
95
+ type: integer
96
+ nullable: false
97
+ - name: name
98
+ type: string
99
+ nullable: false
100
+ - name: email
101
+ type: string
102
+ nullable: false
103
+ limit: 255
104
+ - name: role
105
+ type: string
106
+ default: member
107
+ nullable: true
108
+ indexes:
109
+ - name: index_users_on_email
110
+ columns:
111
+ - email
112
+ unique: true
113
+ associations:
114
+ - type: has_many
115
+ name: posts
116
+ foreign_key: user_id
117
+ - type: has_one
118
+ name: profile
119
+ foreign_key: user_id
120
+ foreign_keys: []
121
+ ```
122
+
123
+ ## Configuration
124
+
125
+ ```ruby
126
+ # config/initializers/sidenotes.rb
127
+ return unless defined?(Sidenotes)
128
+
129
+ Sidenotes.configure do |config|
130
+ # Directory for annotation files (relative to Rails root)
131
+ config.output_directory = ".annotations"
132
+
133
+ # Output format: :yaml (default) or :json
134
+ config.format = :yaml
135
+
136
+ # Sections to include
137
+ # Available: :columns, :indexes, :associations, :foreign_keys,
138
+ # :check_constraints, :metadata
139
+ config.sections = %i[columns indexes associations foreign_keys metadata]
140
+
141
+ # Where to look for model files
142
+ config.model_paths = ["app/models"]
143
+
144
+ # Exclude models by name or pattern
145
+ config.exclude_patterns = [
146
+ "ApplicationRecord",
147
+ /^HABTM_/,
148
+ /^ActiveStorage::/
149
+ ]
150
+ end
151
+ ```
152
+
153
+ ### Configuration reference
154
+
155
+ | Option | Default | Description |
156
+ |---|---|---|
157
+ | `output_directory` | `".annotations"` | Where annotation files are written |
158
+ | `format` | `:yaml` | Output format (`:yaml` or `:json`) |
159
+ | `sections` | `[:columns, :indexes, :associations, :foreign_keys, :metadata]` | Which sections to include |
160
+ | `model_paths` | `["app/models"]` | Directories to scan for models |
161
+ | `exclude_patterns` | `[]` | Strings or regexps to exclude models |
162
+
163
+ ## Namespaced models
164
+
165
+ Namespaced models are written to subdirectories matching their namespace:
166
+
167
+ - `Admin::User` → `.annotations/admin/user.yml`
168
+ - `Api::V2::Widget` → `.annotations/api/v2/widget.yml`
169
+
170
+ ## IDE integration
171
+
172
+ ### VS Code
173
+
174
+ The companion [sidenotes-vscode](https://github.com/1stvamp/sidenotes-vscode) extension reads `.annotations/` files and displays schema information inline as you edit models. Hover over a model name to see its columns, or use the sidebar panel for a full schema overview.
175
+
176
+ Search for "Rails Sidenotes" in the VS Code marketplace.
177
+
178
+ ### JetBrains (RubyMine, IntelliJ)
179
+
180
+ The [sidenotes-jetbrains](https://github.com/1stvamp/sidenotes-jetbrains) plugin provides the same inline schema display for JetBrains IDEs. Install it from the JetBrains Marketplace.
181
+
182
+ ### Other editors
183
+
184
+ The structured YAML/JSON format makes it straightforward to build integrations for any editor. The schema is stable and documented; see the example output above for the full structure.
185
+
186
+ ## Requirements
187
+
188
+ - Ruby >= 3.1
189
+ - Rails >= 6.1
190
+
191
+ ## Development
192
+
193
+ ```bash
194
+ git clone https://github.com/1stvamp/sidenotes-ruby.git
195
+ cd sidenotes
196
+ bundle install
197
+ bundle exec rspec
198
+ ```
199
+
200
+ ## Contributing
201
+
202
+ Bug reports and pull requests are welcome on GitHub at https://github.com/1stvamp/sidenotes-ruby.
203
+
204
+ ## License
205
+
206
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module Sidenotes
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path('templates', __dir__)
9
+
10
+ class_option :gitignore, type: :boolean, default: true,
11
+ desc: 'Add .annotations/ to .gitignore'
12
+
13
+ desc 'Creates an optional Sidenotes initializer for customising configuration'
14
+
15
+ def copy_initializer
16
+ template 'initializer.rb', 'config/initializers/sidenotes.rb'
17
+ end
18
+
19
+ def add_to_gitignore
20
+ return unless options[:gitignore]
21
+
22
+ gitignore = File.join(destination_root, '.gitignore')
23
+ return unless File.exist?(gitignore)
24
+
25
+ content = File.read(gitignore)
26
+ return if content.include?('.annotations/')
27
+
28
+ append_to_file '.gitignore', "\n# Sidenotes schema annotations\n.annotations/\n"
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ return unless defined?(Sidenotes)
4
+
5
+ Sidenotes.configure do |config|
6
+ # Directory where annotation files are generated (relative to Rails root)
7
+ # config.output_directory = ".annotations"
8
+
9
+ # Output format: :yaml (default) or :json
10
+ # config.format = :yaml
11
+
12
+ # Sections to include in annotations
13
+ # Available: :columns, :indexes, :associations, :foreign_keys, :check_constraints, :metadata
14
+ # config.sections = %i[columns indexes associations foreign_keys metadata]
15
+
16
+ # Paths to search for model files (relative to Rails root)
17
+ # config.model_paths = ["app/models"]
18
+
19
+ # Patterns to exclude models (strings or regexps)
20
+ # config.exclude_patterns = ["ApplicationRecord", /^HABTM_/]
21
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidenotes
4
+ class Configuration
5
+ VALID_FORMATS = %i[yaml json].freeze
6
+ VALID_SECTIONS = %i[columns indexes associations foreign_keys check_constraints metadata].freeze
7
+ DEFAULT_SECTIONS = %i[columns indexes associations foreign_keys metadata].freeze
8
+
9
+ attr_reader :output_directory, :format, :sections
10
+ attr_accessor :model_paths, :exclude_patterns
11
+
12
+ def initialize
13
+ @output_directory = '.annotations'
14
+ @format = :yaml
15
+ @sections = DEFAULT_SECTIONS.dup
16
+ @model_paths = ['app/models']
17
+ @exclude_patterns = []
18
+ end
19
+
20
+ def format=(value)
21
+ value = value.to_sym
22
+ unless VALID_FORMATS.include?(value)
23
+ raise ArgumentError, "Invalid format: #{value}. Valid formats: #{VALID_FORMATS.join(', ')}"
24
+ end
25
+
26
+ @format = value
27
+ end
28
+
29
+ def sections=(value)
30
+ value = Array(value).map(&:to_sym)
31
+ invalid = value - VALID_SECTIONS
32
+ unless invalid.empty?
33
+ raise ArgumentError, "Invalid sections: #{invalid.join(', ')}. Valid sections: #{VALID_SECTIONS.join(', ')}"
34
+ end
35
+
36
+ @sections = value
37
+ end
38
+
39
+ def output_directory=(value)
40
+ value = value.to_s
41
+ if value.empty? || value == '.' || value.start_with?('/')
42
+ raise ArgumentError, "output_directory must be a relative path within the project (got: #{value.inspect})"
43
+ end
44
+
45
+ @output_directory = value
46
+ end
47
+
48
+ def file_extension
49
+ case @format
50
+ when :yaml then 'yml'
51
+ when :json then 'json'
52
+ else raise ArgumentError, "Unknown format: #{@format}"
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'json'
5
+
6
+ module Sidenotes
7
+ class Formatter
8
+ HEADER_COMMENT = "# Generated by Sidenotes v%<version>s on %<timestamp>s\n" \
9
+ "# Do not edit manually — regenerate with `rake sidenotes:generate`\n"
10
+
11
+ def initialize(model_name, data)
12
+ @model_name = model_name
13
+ @data = data
14
+ end
15
+
16
+ def render
17
+ case Sidenotes.configuration.format
18
+ when :yaml then render_yaml
19
+ when :json then render_json
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def render_yaml
26
+ header = format(
27
+ HEADER_COMMENT,
28
+ version: Sidenotes::VERSION,
29
+ timestamp: Time.now.utc.strftime('%Y-%m-%d %H:%M:%S UTC')
30
+ )
31
+
32
+ content = { @model_name => @data }
33
+ yaml_output = YAML.dump(content)
34
+ # Remove the leading "---\n" for cleaner output, we'll add the header instead
35
+ yaml_output = yaml_output.sub(/\A---\n/, '')
36
+
37
+ "#{header}---\n#{yaml_output}"
38
+ end
39
+
40
+ def render_json
41
+ content = {
42
+ '_generated_by' => "Sidenotes v#{Sidenotes::VERSION}",
43
+ '_generated_at' => Time.now.utc.strftime('%Y-%m-%d %H:%M:%S UTC'),
44
+ @model_name => @data
45
+ }
46
+
47
+ JSON.pretty_generate(content)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Sidenotes
6
+ class Generator
7
+ attr_reader :models_generated, :models_skipped
8
+
9
+ def initialize
10
+ @models_generated = []
11
+ @models_skipped = []
12
+ end
13
+
14
+ def generate_all
15
+ discover_models.each { |model| generate_for(model) }
16
+ self
17
+ end
18
+
19
+ def generate_for(model)
20
+ model = resolve_model(model)
21
+ inspector = ModelInspector.new(model)
22
+
23
+ unless inspector.inspectable?
24
+ @models_skipped << model.name
25
+ return nil
26
+ end
27
+
28
+ data = inspector.inspect_model
29
+ return nil unless data
30
+
31
+ output = Formatter.new(model.name, data).render
32
+ path = write_file(model, output)
33
+ @models_generated << model.name
34
+ path
35
+ end
36
+
37
+ def clean
38
+ dir = Sidenotes.configuration.output_directory
39
+ return unless File.directory?(dir)
40
+
41
+ remove_annotation_files(dir)
42
+ remove_empty_dirs(dir)
43
+ end
44
+
45
+ def discover_models
46
+ load_model_files
47
+ collect_models
48
+ end
49
+
50
+ private
51
+
52
+ def remove_annotation_files(dir)
53
+ ext = Sidenotes.configuration.file_extension
54
+ Dir.glob(File.join(dir, '**', "*.#{ext}")).each do |f|
55
+ File.delete(f) if File.read(f, 64)&.include?('Sidenotes')
56
+ end
57
+ end
58
+
59
+ def remove_empty_dirs(dir)
60
+ Dir.glob(File.join(dir, '**', '*')).reverse_each do |d|
61
+ Dir.rmdir(d) if File.directory?(d) && Dir.empty?(d)
62
+ end
63
+ Dir.rmdir(dir) if File.directory?(dir) && Dir.empty?(dir)
64
+ end
65
+
66
+ def resolve_model(model)
67
+ return model if model.is_a?(Class)
68
+
69
+ model.to_s.constantize
70
+ end
71
+
72
+ def load_model_files
73
+ return if defined?(Rails) && Rails.application&.config&.eager_load
74
+
75
+ model_file_paths.each do |file|
76
+ require file
77
+ rescue LoadError, NameError => e
78
+ warn "Sidenotes: could not load #{file}: #{e.message}"
79
+ end
80
+ end
81
+
82
+ def model_file_paths
83
+ Sidenotes.configuration.model_paths.flat_map do |path|
84
+ full_path = defined?(Rails) ? Rails.root.join(path) : Pathname.new(path)
85
+ Dir.glob(full_path.join('**', '*.rb'))
86
+ end
87
+ end
88
+
89
+ def collect_models
90
+ models = ActiveRecord::Base.descendants.select do |model|
91
+ next false if model.abstract_class?
92
+ next false if excluded?(model)
93
+
94
+ true
95
+ end
96
+
97
+ models.sort_by(&:name)
98
+ end
99
+
100
+ def excluded?(model)
101
+ config = Sidenotes.configuration
102
+ config.exclude_patterns.any? do |pattern|
103
+ case pattern
104
+ when Regexp then model.name.match?(pattern)
105
+ when String then model.name == pattern
106
+ else false
107
+ end
108
+ end
109
+ end
110
+
111
+ def write_file(model, content)
112
+ config = Sidenotes.configuration
113
+ relative_path = model_to_path(model)
114
+ file_path = File.join(config.output_directory, "#{relative_path}.#{config.file_extension}")
115
+
116
+ FileUtils.mkdir_p(File.dirname(file_path))
117
+ File.write(file_path, content)
118
+ file_path
119
+ end
120
+
121
+ def model_to_path(model)
122
+ # Admin::User => admin/user
123
+ model.name.underscore
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidenotes
4
+ class ModelInspector
5
+ attr_reader :model
6
+
7
+ def initialize(model)
8
+ @model = model
9
+ end
10
+
11
+ SECTION_METHODS = {
12
+ metadata: :metadata,
13
+ columns: :columns,
14
+ indexes: :indexes,
15
+ associations: :associations,
16
+ foreign_keys: :foreign_keys
17
+ }.freeze
18
+
19
+ def inspect_model
20
+ return nil unless inspectable?
21
+
22
+ data = {}
23
+ sections = Sidenotes.configuration.sections
24
+
25
+ SECTION_METHODS.each do |section, method|
26
+ data[section.to_s] = send(method) if sections.include?(section)
27
+ end
28
+
29
+ if sections.include?(:check_constraints) && supports_check_constraints?
30
+ data['check_constraints'] = check_constraints
31
+ end
32
+
33
+ data
34
+ end
35
+
36
+ def inspectable?
37
+ return false if model.abstract_class?
38
+ return false unless model.respond_to?(:table_name)
39
+
40
+ model.table_exists?
41
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
42
+ false
43
+ end
44
+
45
+ private
46
+
47
+ def metadata
48
+ meta = {
49
+ 'table_name' => model.table_name,
50
+ 'primary_key' => model.primary_key
51
+ }
52
+
53
+ add_sti_metadata(meta)
54
+ add_enum_metadata(meta)
55
+ add_encrypted_metadata(meta)
56
+
57
+ meta
58
+ end
59
+
60
+ def add_sti_metadata(meta)
61
+ return unless model.respond_to?(:inheritance_column)
62
+ return unless model.columns_hash.key?(model.inheritance_column)
63
+ return if model.descends_from_active_record?
64
+
65
+ meta['sti_column'] = model.inheritance_column
66
+ end
67
+
68
+ def add_enum_metadata(meta)
69
+ return unless model.respond_to?(:defined_enums) && model.defined_enums.any?
70
+
71
+ meta['enums'] = model.defined_enums.transform_values { |v| v.is_a?(Hash) ? v.keys : v }
72
+ end
73
+
74
+ def add_encrypted_metadata(meta)
75
+ encrypted = model.try(:encrypted_attributes)
76
+ meta['encrypted_attributes'] = encrypted.map(&:to_s) if encrypted&.any?
77
+ end
78
+
79
+ def columns
80
+ model.columns.map { |col| build_column_data(col) }
81
+ end
82
+
83
+ def build_column_data(col)
84
+ data = { 'name' => col.name, 'type' => col.type.to_s }
85
+ data['default'] = col.default unless col.default.nil?
86
+ data['nullable'] = col.null
87
+ data['limit'] = col.limit if col.limit
88
+ data['precision'] = col.precision if col.precision
89
+ data['scale'] = col.scale if col.scale
90
+ data['comment'] = col.comment if col.respond_to?(:comment) && col.comment
91
+ data
92
+ end
93
+
94
+ def indexes
95
+ connection = model.connection
96
+ connection.indexes(model.table_name).map do |idx|
97
+ index_data = {
98
+ 'name' => idx.name,
99
+ 'columns' => Array(idx.columns)
100
+ }
101
+
102
+ index_data['unique'] = idx.unique if idx.unique
103
+ index_data['where'] = idx.where if idx.respond_to?(:where) && idx.where
104
+ index_data['using'] = idx.using.to_s if idx.respond_to?(:using) && idx.using
105
+
106
+ index_data
107
+ end
108
+ end
109
+
110
+ def associations
111
+ model.reflect_on_all_associations.map { |assoc| build_association_data(assoc) }
112
+ end
113
+
114
+ def build_association_data(assoc)
115
+ data = { 'type' => assoc.macro.to_s, 'name' => assoc.name.to_s }
116
+ data['class_name'] = assoc.class_name if assoc.class_name != assoc.name.to_s.classify
117
+ data['foreign_key'] = assoc.foreign_key.to_s if assoc.respond_to?(:foreign_key)
118
+ opts = assoc.respond_to?(:options) ? assoc.options : {}
119
+ data['polymorphic'] = true if opts[:polymorphic]
120
+ data['through'] = opts[:through].to_s if opts[:through]
121
+ data
122
+ end
123
+
124
+ def foreign_keys
125
+ connection = model.connection
126
+ return [] unless connection.respond_to?(:foreign_keys)
127
+
128
+ connection.foreign_keys(model.table_name).map do |fk|
129
+ fk_data = {
130
+ 'from_column' => fk.column,
131
+ 'to_table' => fk.to_table,
132
+ 'to_column' => fk.primary_key
133
+ }
134
+
135
+ fk_data['name'] = fk.name if fk.name
136
+ fk_data['on_delete'] = fk.on_delete.to_s if fk.on_delete
137
+ fk_data['on_update'] = fk.on_update.to_s if fk.on_update
138
+
139
+ fk_data
140
+ end
141
+ end
142
+
143
+ def check_constraints
144
+ connection = model.connection
145
+ return [] unless connection.respond_to?(:check_constraints)
146
+
147
+ connection.check_constraints(model.table_name).map do |cc|
148
+ {
149
+ 'name' => cc.name,
150
+ 'expression' => cc.expression
151
+ }
152
+ end
153
+ rescue NotImplementedError
154
+ []
155
+ end
156
+
157
+ def supports_check_constraints?
158
+ model.connection.respond_to?(:check_constraints)
159
+ rescue StandardError
160
+ false
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidenotes
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ namespace :sidenotes do
7
+ desc 'Generate annotation files for all models'
8
+ task generate: :environment do
9
+ Sidenotes::Railtie.run_generate
10
+ end
11
+
12
+ desc 'Remove all annotation files'
13
+ task clean: :environment do
14
+ Sidenotes::Generator.new.clean
15
+ puts "Sidenotes: Cleaned annotation files from #{Sidenotes.configuration.output_directory}"
16
+ end
17
+
18
+ desc 'Generate annotation for a single model (MODEL=User)'
19
+ task model: :environment do
20
+ Sidenotes::Railtie.run_model
21
+ end
22
+ end
23
+ end
24
+
25
+ def self.run_generate
26
+ generator = Sidenotes::Generator.new
27
+ generator.generate_all
28
+
29
+ puts "Sidenotes: Generated #{generator.models_generated.size} annotation(s)"
30
+ generator.models_generated.each { |m| puts " #{m}" }
31
+
32
+ return unless generator.models_skipped.any?
33
+
34
+ puts "Skipped #{generator.models_skipped.size} model(s):"
35
+ generator.models_skipped.each { |m| puts " #{m} (skipped)" }
36
+ end
37
+
38
+ def self.run_model
39
+ model_name = ENV.fetch('MODEL', nil)
40
+ abort 'Usage: rake sidenotes:model MODEL=User' unless model_name
41
+
42
+ generator = Sidenotes::Generator.new
43
+ path = generator.generate_for(model_name)
44
+
45
+ if path
46
+ puts "Sidenotes: Generated #{path}"
47
+ else
48
+ puts "Sidenotes: Could not generate annotation for #{model_name}"
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidenotes
4
+ VERSION = '0.1.0'
5
+ end
data/lib/sidenotes.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'sidenotes/version'
4
+ require_relative 'sidenotes/configuration'
5
+ require_relative 'sidenotes/model_inspector'
6
+ require_relative 'sidenotes/formatter'
7
+ require_relative 'sidenotes/generator'
8
+
9
+ module Sidenotes
10
+ class Error < StandardError; end
11
+
12
+ class << self
13
+ def configuration
14
+ @configuration ||= Configuration.new
15
+ end
16
+
17
+ def configure
18
+ yield(configuration)
19
+ end
20
+
21
+ def reset_configuration!
22
+ @configuration = Configuration.new
23
+ end
24
+ end
25
+ end
26
+
27
+ require_relative 'sidenotes/railtie' if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,105 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sidenotes
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Wes Mason
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-04-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '6.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '6.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: railties
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '6.1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '6.1'
55
+ description: Generates structured schema annotation files for Rails models as gitignored
56
+ sidecar files, replacing inline comments with separate metadata files that IDEs
57
+ and tools can consume.
58
+ email:
59
+ - wesley.mason@pinpointhq.com
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - ".rubocop.yml"
65
+ - CHANGELOG.md
66
+ - LICENSE
67
+ - README.md
68
+ - Rakefile
69
+ - lib/generators/sidenotes/install_generator.rb
70
+ - lib/generators/sidenotes/templates/initializer.rb
71
+ - lib/sidenotes.rb
72
+ - lib/sidenotes/configuration.rb
73
+ - lib/sidenotes/formatter.rb
74
+ - lib/sidenotes/generator.rb
75
+ - lib/sidenotes/model_inspector.rb
76
+ - lib/sidenotes/railtie.rb
77
+ - lib/sidenotes/version.rb
78
+ homepage: https://github.com/1stvamp/sidenotes-ruby
79
+ licenses:
80
+ - MIT
81
+ metadata:
82
+ homepage_uri: https://github.com/1stvamp/sidenotes-ruby
83
+ source_code_uri: https://github.com/1stvamp/sidenotes-ruby
84
+ changelog_uri: https://github.com/1stvamp/sidenotes-ruby/blob/main/CHANGELOG.md
85
+ rubygems_mfa_required: 'true'
86
+ post_install_message:
87
+ rdoc_options: []
88
+ require_paths:
89
+ - lib
90
+ required_ruby_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: 3.1.0
95
+ required_rubygems_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ requirements: []
101
+ rubygems_version: 3.5.22
102
+ signing_key:
103
+ specification_version: 4
104
+ summary: Structured YAML/JSON schema annotations for Rails models as sidecar files
105
+ test_files: []