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 +7 -0
- data/.rubocop.yml +22 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE +21 -0
- data/README.md +206 -0
- data/Rakefile +8 -0
- data/lib/generators/sidenotes/install_generator.rb +32 -0
- data/lib/generators/sidenotes/templates/initializer.rb +21 -0
- data/lib/sidenotes/configuration.rb +56 -0
- data/lib/sidenotes/formatter.rb +50 -0
- data/lib/sidenotes/generator.rb +126 -0
- data/lib/sidenotes/model_inspector.rb +163 -0
- data/lib/sidenotes/railtie.rb +52 -0
- data/lib/sidenotes/version.rb +5 -0
- data/lib/sidenotes.rb +27 -0
- metadata +105 -0
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
|
+
[](https://github.com/1stvamp/sidenotes-ruby/actions/workflows/ci.yml)
|
|
4
|
+
[](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,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
|
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: []
|