annotato 0.1.15 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c9018e17dc7b82a75e12b7e43cf5845abf9d397512cfc987aaf40dc4c70af045
4
- data.tar.gz: ba4b35b5f17dbeebe540e32bc5bbfa4b124667915072bc73d6690429b5e3777f
3
+ metadata.gz: ad60c697f9ee0213951d02981190dee26a8db0a2e48ab36d3118c1aaf95d4d40
4
+ data.tar.gz: 43a5f89b6509a636d579416c92bbbc52c63e15b8029701b46c525bd651a049a1
5
5
  SHA512:
6
- metadata.gz: 2d573afafb8041a325f75eca8cf1ee9396520e380d28cd5a293b375e17b60f9bb872525c99a7cf0df7a54da402d5a89cadb36ec5cefbdd6906340f8bcae49a40
7
- data.tar.gz: 0753e255496f9f03ec1d91ab54317ff435d2133d9e0dee7fa285c30bdef6a8692abe4a79a7d2412784436ec2696bed060feb30c5c0efde384ffcc75ce9c08273
6
+ metadata.gz: 7753fdc81bfc3568053c99f3f4f98107d0ca3b2ad1b64b127e19902e00f35a8b889b86b2219a431ea2dd673bab86000d0705b59d7a88ef1c68192be2cf012d3e
7
+ data.tar.gz: d05dd1bb4dd969a77555c3685ab129a9f31a479c5d2f69ef80c9ed7971677c044bc918ec2fd7e1212bc90eb49e678acd306f8afe66d7c9b743ea3b9dc3b9e650
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- annotato (0.1.15)
4
+ annotato (0.2.1)
5
5
  rails (>= 6.0)
6
6
 
7
7
  GEM
@@ -214,6 +214,7 @@ GEM
214
214
 
215
215
  PLATFORMS
216
216
  arm64-darwin-24
217
+ arm64-darwin-25
217
218
  x86_64-linux
218
219
 
219
220
  DEPENDENCIES
data/README.md CHANGED
@@ -16,6 +16,7 @@
16
16
  * Enums with values
17
17
  * Indexes (only if present)
18
18
  * Triggers (only if present)
19
+ * Check Constraints (only if present)
19
20
  * Skips unchanged annotations
20
21
  * Smart formatting with aligned columns
21
22
 
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "wrap_helper"
3
4
  require_relative "column_formatter"
4
5
  require_relative "index_formatter"
5
6
  require_relative "trigger_formatter"
6
7
  require_relative "line_formatter"
7
8
  require_relative "enum_formatter"
9
+ require_relative "check_constraint_formatter"
8
10
 
9
11
  module Annotato
10
12
  class AnnotationBuilder
@@ -20,7 +22,7 @@ module Annotato
20
22
  lines << "Columns:"
21
23
  lines += ColumnFormatter.format(model, conn)
22
24
 
23
- enums = EnumFormatter.format(model)
25
+ enums = EnumFormatter.format(conn, conn.columns(table_name))
24
26
  unless enums.empty?
25
27
  lines << ""
26
28
  lines << "Enums:"
@@ -41,6 +43,13 @@ module Annotato
41
43
  lines += triggers
42
44
  end
43
45
 
46
+ check_constraints = CheckConstraintFormatter.format(conn, table_name)
47
+ unless check_constraints.empty?
48
+ lines << ""
49
+ lines << "Check Constraints:"
50
+ lines += check_constraints
51
+ end
52
+
44
53
  LineFormatter.format(lines)
45
54
  end
46
55
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Annotato
4
+ class CheckConstraintFormatter
5
+ extend WrapHelper
6
+
7
+ def self.format(conn, table_name)
8
+ conn.check_constraints(table_name).map do |chk|
9
+ expr_clause = ""
10
+ if chk.expression
11
+ expr_clause = "\n" + wrap_sql(chk.expression, first_prefix: "# (", cont_prefix: "# ")
12
+ end
13
+ "# #{chk.name}#{expr_clause}"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -9,9 +9,11 @@ module Annotato
9
9
  table_name = model.table_name
10
10
  primary_key = model.primary_key
11
11
  unique_indexes = connection.indexes(table_name).select(&:unique)
12
- enums = model.defined_enums
13
12
  columns = connection.columns(table_name)
14
13
 
14
+ # Collect native PG enum type names once up front (avoids N+1 queries).
15
+ pg_enum_types = pg_enum_type_names(connection)
16
+
15
17
  # Compute max widths for name and sql_type so that the table lines up.
16
18
  name_width = columns.map(&:name).map(&:length).max
17
19
  type_width = columns.map(&:sql_type).map(&:length).max
@@ -20,8 +22,6 @@ module Annotato
20
22
  name = col.name
21
23
  type = col.sql_type
22
24
 
23
- # require "pry" if name == "allowed_statuses"
24
- # binding.pry if name == "allowed_statuses"
25
25
  # Build the left-hand side and calculate indent.
26
26
  left = "# %-#{name_width}s :%-#{type_width}s" % [name, type]
27
27
  # " default(" is 9 chars; +2 gives the extra gap.
@@ -41,7 +41,8 @@ module Annotato
41
41
  opts << "primary key" if name == primary_key
42
42
  opts << "is an Array" if type.end_with?("[]")
43
43
  opts << "unique" if unique_indexes.any? { |idx| idx.columns == [name] }
44
- opts << "enum" if enums.key?(name)
44
+ opts << "enum" if pg_enum_types.include?(type.delete_suffix("[]"))
45
+ opts << "comment: #{col.comment.inspect}" if col.respond_to?(:comment) && col.comment && !col.comment.empty?
45
46
 
46
47
  # Emit either a multiline block or a single line.
47
48
  if default_block
@@ -64,9 +65,8 @@ module Annotato
64
65
  end
65
66
  end
66
67
 
67
- # Returns nil (no default) or an Array of un-indented lines:
68
- # ["[", "\"A\",", "\"B\"", "]"] or ["{", "\"k\":v,", ... , "}"]
69
- # If the value is empty array or hash, returns ["[]"] or ["{}"].
68
+ # Returns nil for non-JSON/empty values, or an Array of un-indented inner lines
69
+ # for multiline formatting: e.g. ["\"A\",", "\"B\""] for a JSON array.
70
70
  def self.build_default_block(value)
71
71
  return nil if value.nil? || !value.is_a?(String)
72
72
  s = value.strip
@@ -76,15 +76,15 @@ module Annotato
76
76
  return nil unless parsed.is_a?(Array) || parsed.is_a?(Hash)
77
77
 
78
78
  if parsed.is_a?(Array)
79
- return if parsed.empty? # empty array → ["[]"]
79
+ return nil if parsed.empty?
80
80
 
81
- # Only a JSON array of strings?
81
+ # JSON array of strings
82
82
  parsed.map.with_index do |e, i|
83
83
  comma = i == parsed.size - 1 ? "" : ","
84
84
  %Q{"#{e}"#{comma}}
85
85
  end
86
86
  else
87
- return if parsed.empty? # empty hash → ["{}"]
87
+ return nil if parsed.empty?
88
88
 
89
89
  # JSON hash → key/value pairs
90
90
  parsed.map.with_index do |(k, v), i|
@@ -93,5 +93,16 @@ module Annotato
93
93
  end
94
94
  end
95
95
  end
96
+
97
+ # Fetches all native PostgreSQL enum type names in one query.
98
+ # Returns a Set for O(1) membership checks.
99
+ def self.pg_enum_type_names(connection)
100
+ rows = connection.exec_query(
101
+ "SELECT typname FROM pg_type WHERE typtype = 'e'",
102
+ "SQL"
103
+ )
104
+ rows.map { |r| r["typname"] }.to_set
105
+ end
106
+ private_class_method :pg_enum_type_names
96
107
  end
97
108
  end
@@ -1,26 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Annotato
4
+ # Formats PostgreSQL native enum type definitions for columns whose sql_type
5
+ # refers to a custom DB enum (e.g. `access_link_category`). Queries pg_enum
6
+ # directly so the output reflects the actual database definition, not the
7
+ # ActiveRecord-level enum mapping already visible in the model source.
4
8
  class EnumFormatter
5
- def self.format(model)
6
- return [] if model.defined_enums.empty?
9
+ def self.format(conn, columns)
10
+ # Collect columns backed by a native PG enum type.
11
+ # A native enum type is one that exists in pg_type with typtype = 'e'.
12
+ enum_columns = columns.select { |col| pg_enum_type?(conn, col.sql_type) }
13
+ return [] if enum_columns.empty?
7
14
 
8
- model.defined_enums.map do |attr, values|
9
- lines = ["# #{attr}: {"]
10
-
11
- formatted_values = values.map do |key, val|
12
- if key.to_s == val.to_s
13
- "#{key}"
14
- else
15
- "#{key} (#{val})"
16
- end
17
- end
18
-
19
- lines += formatted_values.map { |v| "# #{v}," }
20
- lines[-1] = lines[-1].chomp(',') # remove trailing comma from last line
21
- lines << "# }"
15
+ enum_columns.map do |col|
16
+ labels = pg_enum_labels(conn, col.sql_type)
17
+ lines = ["# #{col.name} (#{col.sql_type}): ["]
18
+ lines += labels.map.with_index { |label, i|
19
+ comma = i == labels.size - 1 ? "" : ","
20
+ "# #{label}#{comma}"
21
+ }
22
+ lines << "# ]"
22
23
  lines.join("\n")
23
24
  end
24
25
  end
26
+
27
+ private_class_method def self.pg_enum_type?(conn, sql_type)
28
+ # Strip array suffix (e.g. "my_enum[]") before checking.
29
+ type_name = sql_type.delete_suffix("[]")
30
+ result = conn.exec_query(
31
+ "SELECT 1 FROM pg_type WHERE typname = $1 AND typtype = 'e' LIMIT 1",
32
+ "SQL",
33
+ [type_name]
34
+ )
35
+ result.any?
36
+ end
37
+
38
+ private_class_method def self.pg_enum_labels(conn, sql_type)
39
+ type_name = sql_type.delete_suffix("[]")
40
+ conn.exec_query(
41
+ "SELECT e.enumlabel FROM pg_enum e " \
42
+ "JOIN pg_type t ON e.enumtypid = t.oid " \
43
+ "WHERE t.typname = $1 " \
44
+ "ORDER BY e.enumsortorder",
45
+ "SQL",
46
+ [type_name]
47
+ ).map { |r| r["enumlabel"] }
48
+ end
25
49
  end
26
50
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Annotato
4
4
  class IndexFormatter
5
- MAX_LINE = 100
5
+ extend WrapHelper
6
6
 
7
7
  def self.format(conn, table_name)
8
8
  conn.indexes(table_name).map do |idx|
@@ -11,75 +11,11 @@ module Annotato
11
11
 
12
12
  where_clause = ""
13
13
  if idx.where
14
- where_clause = "\n" + wrap_where(idx.where, max_len: MAX_LINE)
14
+ where_clause = "\n" + wrap_sql(idx.where, first_prefix: "# where (", cont_prefix: "# ")
15
15
  end
16
16
 
17
17
  "# #{idx.name} (#{cols_list})#{unique_clause}#{where_clause}"
18
18
  end
19
19
  end
20
-
21
- # Produces one or more lines, each already prefixed with "# "
22
- # First line: "# where (..."
23
- # Next lines: "# ..." (aligned under the "(")
24
- def self.wrap_where(where_sql, max_len:)
25
- first_prefix = "# where ("
26
- cont_prefix = "# " # aligns under the "(" after "where "
27
-
28
- text = where_sql.to_s
29
- lines = []
30
-
31
- # If it already fits, keep it one-liner
32
- if (first_prefix.length + text.length + 1) <= max_len
33
- return "#{first_prefix}#{text})"
34
- end
35
-
36
- remaining = text.dup
37
- current_prefix = first_prefix
38
-
39
- while remaining.length > 0
40
- available = max_len - current_prefix.length - 1 # -1 for closing ")" on last line (or just safety)
41
- available = 20 if available < 20
42
-
43
- if remaining.length <= available
44
- lines << "#{current_prefix}#{remaining}"
45
- remaining = ""
46
- break
47
- end
48
-
49
- # Prefer breaking on logical operators within the window
50
- window = remaining[0, available]
51
- cut =
52
- window.rindex(" AND ") ||
53
- window.rindex(" OR ") ||
54
- window.rindex(", ") ||
55
- window.rindex(") ") ||
56
- window.rindex(" ") # last resort: whitespace
57
-
58
- # If we found a breakpoint, include it in the line (so operators stay visible)
59
- if cut
60
- # If we’re cutting on AND/OR/comma, keep that token at the end of the line when possible
61
- if window[cut, 5] == " AND " || window[cut, 4] == " OR "
62
- cut += (window[cut, 5] == " AND " ? 5 : 4)
63
- elsif window[cut, 2] == ", "
64
- cut += 2
65
- elsif window[cut, 2] == ") "
66
- cut += 2
67
- else
68
- cut += 1
69
- end
70
- else
71
- cut = available
72
- end
73
-
74
- lines << "#{current_prefix}#{remaining[0, cut].rstrip}"
75
- remaining = remaining[cut..].to_s.lstrip
76
- current_prefix = cont_prefix
77
- end
78
-
79
- # Close the paren on the last line
80
- lines[-1] = "#{lines[-1]})"
81
-
82
- lines.join("\n")
83
- end
84
20
  end
85
21
  end
@@ -19,7 +19,6 @@ module Annotato
19
19
  annotation = AnnotationBuilder.build(model)
20
20
  write_annotation(model, annotation)
21
21
  end
22
- @output.puts "✅ Annotato completed"
23
22
  end
24
23
 
25
24
  private
@@ -46,7 +45,13 @@ module Annotato
46
45
  file = model_file(model)
47
46
  return unless file && File.exist?(file)
48
47
 
49
- content = File.read(file).dup # <--- duplicate string to avoid FrozenError
48
+ content = File.read(file).dup
49
+
50
+ # Verify the class definition exists before doing anything else.
51
+ unless content.match?(/class\s+\w+/)
52
+ @output.puts "⚠️ Skipped #{model.name} — class not found in file"
53
+ return
54
+ end
50
55
 
51
56
  # Extract the old Annotato annotation block if it exists
52
57
  old_annotation = content[/^# == Annotato Schema Info.*?(?=^class|\z)/m]
@@ -57,17 +62,11 @@ module Annotato
57
62
  return
58
63
  end
59
64
 
60
- # Remove old Annotato blocks
65
+ # Remove old Annotato blocks and legacy annotate-gem blocks
61
66
  content.gsub!(/^# == Annotato Schema Info.*?(?=^class|\z)/m, "")
62
67
  content.gsub!(/^# == Schema Information.*?(?=^class|\z)/m, "")
63
68
  content.rstrip!
64
69
 
65
- # Verify the class definition exists before appending annotation
66
- unless content.match?(/class\s+\w+/)
67
- @output.puts "⚠️ Skipped #{model.name} — class not found in file"
68
- return
69
- end
70
-
71
70
  # Append the new annotation at the end of the file
72
71
  content += "\n\n#{annotation}\n"
73
72
  File.write(file, content)
@@ -3,11 +3,11 @@
3
3
  module Annotato
4
4
  class TriggerFormatter
5
5
  def self.format(conn, table_name)
6
- conn.exec_query(<<~SQL).map { |r| "# #{r['tgname']}" }
7
- SELECT tgname FROM pg_trigger
8
- WHERE tgrelid = '#{table_name}'::regclass
9
- AND NOT tgisinternal;
10
- SQL
6
+ conn.exec_query(
7
+ "SELECT tgname FROM pg_trigger WHERE tgrelid = $1::regclass AND NOT tgisinternal",
8
+ "SQL",
9
+ [table_name]
10
+ ).map { |r| "# #{r['tgname']}" }
11
11
  end
12
12
  end
13
13
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Annotato
4
- VERSION = "0.1.15"
4
+ VERSION = "0.2.1"
5
5
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Annotato
4
+ module WrapHelper
5
+ MAX_LINE = 100
6
+
7
+ # Produces one or more lines, each already prefixed with "# "
8
+ # First line: "# where (..."
9
+ # Next lines: "# ..." (aligned under the "(")
10
+ def wrap_sql(sql, first_prefix:, cont_prefix:, max_len: MAX_LINE)
11
+ text = sql.to_s
12
+ lines = []
13
+
14
+ # If it already fits, keep it one-liner
15
+ if (first_prefix.length + text.length + 1) <= max_len
16
+ return "#{first_prefix}#{text})"
17
+ end
18
+
19
+ remaining = text.dup
20
+ current_prefix = first_prefix
21
+
22
+ while remaining.length > 0
23
+ available = max_len - current_prefix.length - 1 # -1 for closing ")" on last line (or just safety)
24
+ available = 20 if available < 20
25
+
26
+ if remaining.length <= available
27
+ lines << "#{current_prefix}#{remaining}"
28
+ remaining = ""
29
+ break
30
+ end
31
+
32
+ # Prefer breaking on logical operators within the window
33
+ window = remaining[0, available]
34
+ cut =
35
+ window.rindex(" AND ") ||
36
+ window.rindex(" OR ") ||
37
+ window.rindex(", ") ||
38
+ window.rindex(") ") ||
39
+ window.rindex(" ") # last resort: whitespace
40
+
41
+ # If we found a breakpoint, include it in the line (so operators stay visible)
42
+ if cut
43
+ # If we’re cutting on AND/OR/comma, keep that token at the end of the line when possible
44
+ if window[cut, 5] == " AND " || window[cut, 4] == " OR "
45
+ cut += (window[cut, 5] == " AND " ? 5 : 4)
46
+ elsif window[cut, 2] == ", "
47
+ cut += 2
48
+ elsif window[cut, 2] == ") "
49
+ cut += 2
50
+ else
51
+ cut += 1
52
+ end
53
+ else
54
+ cut = available
55
+ end
56
+
57
+ lines << "#{current_prefix}#{remaining[0, cut].rstrip}"
58
+ remaining = remaining[cut..].to_s.lstrip
59
+ current_prefix = cont_prefix
60
+ end
61
+
62
+ # Close the paren on the last line
63
+ lines[-1] = "#{lines[-1]})"
64
+
65
+ lines.join("\n")
66
+ end
67
+ end
68
+ end
@@ -19,5 +19,7 @@ namespace :annotato do
19
19
  # No MODEL passed → annotate all models
20
20
  annotator.run
21
21
  end
22
+
23
+ puts "✅ Annotato completed"
22
24
  end
23
25
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: annotato
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.15
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Serhii Bodnaruk
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-12-19 00:00:00.000000000 Z
10
+ date: 2026-06-30 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -45,6 +45,7 @@ files:
45
45
  - bin/setup
46
46
  - lib/annotato.rb
47
47
  - lib/annotato/annotation_builder.rb
48
+ - lib/annotato/check_constraint_formatter.rb
48
49
  - lib/annotato/column_formatter.rb
49
50
  - lib/annotato/enum_formatter.rb
50
51
  - lib/annotato/index_formatter.rb
@@ -53,6 +54,7 @@ files:
53
54
  - lib/annotato/railtie.rb
54
55
  - lib/annotato/trigger_formatter.rb
55
56
  - lib/annotato/version.rb
57
+ - lib/annotato/wrap_helper.rb
56
58
  - lib/tasks/annotato.rake
57
59
  - sig/annotato.rbs
58
60
  homepage: https://github.com/boserh/annotato