noiseless 0.7.2 → 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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/noiseless/adapter.rb +28 -35
- data/lib/noiseless/adapters/elasticsearch.rb +0 -1
- data/lib/noiseless/adapters/execution_modules/elasticsearch_execution.rb +8 -6
- data/lib/noiseless/adapters/execution_modules/es_compatible_execution.rb +65 -17
- data/lib/noiseless/adapters/execution_modules/http_transport.rb +29 -18
- data/lib/noiseless/adapters/execution_modules/opensearch_execution.rb +102 -114
- data/lib/noiseless/adapters/execution_modules/pgvector_support.rb +42 -47
- data/lib/noiseless/adapters/execution_modules/postgresql_execution.rb +63 -324
- data/lib/noiseless/adapters/execution_modules/postgresql_query.rb +269 -0
- data/lib/noiseless/adapters/execution_modules/typesense_execution.rb +172 -296
- data/lib/noiseless/adapters/execution_modules/typesense_query.rb +143 -0
- data/lib/noiseless/adapters/indices_api.rb +3 -1
- data/lib/noiseless/adapters/open_search.rb +30 -88
- data/lib/noiseless/adapters/postgresql.rb +5 -5
- data/lib/noiseless/adapters/typesense.rb +1 -2
- data/lib/noiseless/ast/aggregation.rb +0 -23
- data/lib/noiseless/ast/aggregation_builder.rb +28 -0
- data/lib/noiseless/ast/hybrid.rb +4 -2
- data/lib/noiseless/ast/join.rb +4 -5
- data/lib/noiseless/ast/vector.rb +25 -4
- data/lib/noiseless/bulk_importer.rb +16 -27
- data/lib/noiseless/callbacks.rb +4 -30
- data/lib/noiseless/connection_manager.rb +9 -3
- data/lib/noiseless/controller_runtime.rb +37 -0
- data/lib/noiseless/document_manager.rb +21 -36
- data/lib/noiseless/embedding.rb +33 -0
- data/lib/noiseless/instrumentation.rb +2 -136
- data/lib/noiseless/introspection/mermaid_diagrams.rb +143 -0
- data/lib/noiseless/introspection/query_visualizer.rb +3 -228
- data/lib/noiseless/introspection.rb +1 -1
- data/lib/noiseless/log_subscriber.rb +103 -0
- data/lib/noiseless/model.rb +2 -2
- data/lib/noiseless/multi_search.rb +2 -4
- data/lib/noiseless/pagination.rb +3 -3
- data/lib/noiseless/query_builder.rb +5 -4
- data/lib/noiseless/railtie.rb +0 -2
- data/lib/noiseless/response/records.rb +2 -1
- data/lib/noiseless/runtime_reset_middleware.rb +1 -1
- data/lib/noiseless/search_index_update_job.rb +13 -14
- data/lib/noiseless/test_case.rb +0 -1
- data/lib/noiseless/version.rb +1 -1
- data/lib/noiseless.rb +1 -10
- metadata +31 -13
- data/lib/tasks/benchmark.rake +0 -35
data/lib/noiseless/callbacks.rb
CHANGED
|
@@ -7,8 +7,6 @@ module Noiseless
|
|
|
7
7
|
extend ActiveSupport::Concern
|
|
8
8
|
|
|
9
9
|
included do
|
|
10
|
-
after_save :update_search_index_on_save
|
|
11
|
-
after_destroy :remove_from_search_index
|
|
12
10
|
after_commit :update_search_index_on_commit, on: %i[create update]
|
|
13
11
|
after_commit :remove_from_search_index_on_commit, on: :destroy
|
|
14
12
|
end
|
|
@@ -28,11 +26,11 @@ module Noiseless
|
|
|
28
26
|
end
|
|
29
27
|
|
|
30
28
|
def skip_auto_index
|
|
31
|
-
previous_value =
|
|
32
|
-
|
|
29
|
+
previous_value = Fiber[:noiseless_skip_auto_index]
|
|
30
|
+
Fiber[:noiseless_skip_auto_index] = true
|
|
33
31
|
yield
|
|
34
32
|
ensure
|
|
35
|
-
|
|
33
|
+
Fiber[:noiseless_skip_auto_index] = previous_value
|
|
36
34
|
end
|
|
37
35
|
end
|
|
38
36
|
|
|
@@ -40,7 +38,7 @@ module Noiseless
|
|
|
40
38
|
|
|
41
39
|
def should_update_search_index?
|
|
42
40
|
return false unless Noiseless.config.auto_index
|
|
43
|
-
return false if
|
|
41
|
+
return false if Fiber[:noiseless_skip_auto_index]
|
|
44
42
|
return false unless self.class.auto_index_enabled?
|
|
45
43
|
|
|
46
44
|
# Only update if we have searchable content
|
|
@@ -51,14 +49,6 @@ module Noiseless
|
|
|
51
49
|
end
|
|
52
50
|
end
|
|
53
51
|
|
|
54
|
-
def update_search_index_on_save
|
|
55
|
-
return unless should_update_search_index?
|
|
56
|
-
|
|
57
|
-
update_search_index_async if noiseless_new_record? || (respond_to?(:changed?) && changed?)
|
|
58
|
-
rescue Noiseless::Error => e
|
|
59
|
-
handle_search_index_error(e, :update)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
52
|
def update_search_index_on_commit
|
|
63
53
|
return unless should_update_search_index?
|
|
64
54
|
|
|
@@ -67,14 +57,6 @@ module Noiseless
|
|
|
67
57
|
handle_search_index_error(e, :update)
|
|
68
58
|
end
|
|
69
59
|
|
|
70
|
-
def remove_from_search_index
|
|
71
|
-
return unless should_update_search_index?
|
|
72
|
-
|
|
73
|
-
remove_from_search_index_async
|
|
74
|
-
rescue Noiseless::Error => e
|
|
75
|
-
handle_search_index_error(e, :delete)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
60
|
def remove_from_search_index_on_commit
|
|
79
61
|
return unless should_update_search_index?
|
|
80
62
|
|
|
@@ -127,13 +109,5 @@ module Noiseless
|
|
|
127
109
|
logger.error "Noiseless: Failed to #{operation} search index for #{self.class.name}##{id}: #{error.message}"
|
|
128
110
|
end
|
|
129
111
|
end
|
|
130
|
-
|
|
131
|
-
def noiseless_new_record?
|
|
132
|
-
if respond_to?(:persisted?)
|
|
133
|
-
!persisted?
|
|
134
|
-
else
|
|
135
|
-
!id
|
|
136
|
-
end
|
|
137
|
-
end
|
|
138
112
|
end
|
|
139
113
|
end
|
|
@@ -9,8 +9,10 @@ module Noiseless
|
|
|
9
9
|
|
|
10
10
|
# Register a named client statically from YAML (boot-time only).
|
|
11
11
|
# request_timeout: :default uses the adapter default, nil disables the deadline.
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
# Any other keys (e.g. api_key) are passed to the adapter unchanged.
|
|
13
|
+
def register(name, adapter:, hosts:, timeout: nil, request_timeout: :default, **options)
|
|
14
|
+
@configs[name.to_sym] = { adapter: adapter, hosts: hosts, timeout: timeout, request_timeout: request_timeout,
|
|
15
|
+
options: options }
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
# Retrieve a client; defaults to :primary
|
|
@@ -20,11 +22,15 @@ module Noiseless
|
|
|
20
22
|
# Lazy-load the adapter only when actually used
|
|
21
23
|
@clients[name] ||= begin
|
|
22
24
|
config = @configs.fetch(name) { raise "Unknown connection: #{name}" }
|
|
23
|
-
params = { hosts: config[:hosts] }
|
|
25
|
+
params = { **config[:options], hosts: config[:hosts] }
|
|
24
26
|
params[:timeout] = config[:timeout] unless config[:timeout].nil?
|
|
25
27
|
params[:request_timeout] = config[:request_timeout] unless config[:request_timeout] == :default
|
|
26
28
|
Adapters.lookup(config[:adapter], **params)
|
|
27
29
|
end
|
|
28
30
|
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def instance_variables_to_inspect = instance_variables - %i[@configs]
|
|
29
35
|
end
|
|
30
36
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Noiseless
|
|
4
|
+
# Runtime tracking for Rails
|
|
5
|
+
module ControllerRuntime
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
protected
|
|
9
|
+
|
|
10
|
+
def append_info_to_payload(payload)
|
|
11
|
+
super
|
|
12
|
+
payload[:noiseless_runtime] = noiseless_runtime
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def cleanup_view_runtime
|
|
16
|
+
runtime_before_render = noiseless_runtime
|
|
17
|
+
runtime = super
|
|
18
|
+
runtime_after_render = noiseless_runtime
|
|
19
|
+
runtime + runtime_after_render - runtime_before_render
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def noiseless_runtime
|
|
25
|
+
ActiveSupport::IsolatedExecutionState[:noiseless_runtime] ||= 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
module ClassMethods
|
|
29
|
+
def log_process_action(payload)
|
|
30
|
+
messages = super
|
|
31
|
+
runtime = payload[:noiseless_runtime]
|
|
32
|
+
messages << ("Noiseless: %.1fms" % runtime) if runtime&.positive?
|
|
33
|
+
messages
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -11,14 +11,7 @@ module Noiseless
|
|
|
11
11
|
document = build_document
|
|
12
12
|
return false unless document
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
client.index_document(
|
|
16
|
-
index: index_name,
|
|
17
|
-
id: document_id,
|
|
18
|
-
document: document,
|
|
19
|
-
refresh: refresh,
|
|
20
|
-
**
|
|
21
|
-
)
|
|
14
|
+
await { client.index_document(index: index_name, id: document_id, document: document, refresh: refresh, **) }
|
|
22
15
|
end
|
|
23
16
|
|
|
24
17
|
def update_document(refresh: false, detect_changes: true, **)
|
|
@@ -28,14 +21,7 @@ module Noiseless
|
|
|
28
21
|
changes = extract_changes
|
|
29
22
|
return false if changes.empty?
|
|
30
23
|
|
|
31
|
-
|
|
32
|
-
client.update_document(
|
|
33
|
-
index: index_name,
|
|
34
|
-
id: document_id,
|
|
35
|
-
changes: changes,
|
|
36
|
-
refresh: refresh,
|
|
37
|
-
**
|
|
38
|
-
)
|
|
24
|
+
await { client.update_document(index: index_name, id: document_id, changes: changes, refresh: refresh, **) }
|
|
39
25
|
else
|
|
40
26
|
# Fall back to full document update
|
|
41
27
|
index_document(refresh: refresh, **)
|
|
@@ -43,35 +29,34 @@ module Noiseless
|
|
|
43
29
|
end
|
|
44
30
|
|
|
45
31
|
def delete_document(refresh: false, **)
|
|
46
|
-
|
|
47
|
-
client.delete_document(
|
|
48
|
-
index: index_name,
|
|
49
|
-
id: document_id,
|
|
50
|
-
refresh: refresh,
|
|
51
|
-
**
|
|
52
|
-
)
|
|
32
|
+
await { client.delete_document(index: index_name, id: document_id, refresh: refresh, **) }
|
|
53
33
|
end
|
|
54
34
|
|
|
55
35
|
def document_exists?
|
|
56
|
-
|
|
57
|
-
client.document_exists?(
|
|
58
|
-
index: index_name,
|
|
59
|
-
id: document_id
|
|
60
|
-
)
|
|
36
|
+
await { client.document_exists?(index: index_name, id: document_id) }
|
|
61
37
|
end
|
|
62
38
|
|
|
63
39
|
private
|
|
64
40
|
|
|
41
|
+
def client = Noiseless.connections.client(@connection)
|
|
42
|
+
|
|
43
|
+
def await = Sync { yield.wait }
|
|
44
|
+
|
|
65
45
|
attr_reader :model_instance
|
|
66
46
|
|
|
67
47
|
def build_document
|
|
68
|
-
if model_instance.respond_to?(:to_search_document)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
48
|
+
document = if model_instance.respond_to?(:to_search_document)
|
|
49
|
+
model_instance.to_search_document
|
|
50
|
+
elsif model_instance.respond_to?(:to_h)
|
|
51
|
+
model_instance.to_h
|
|
52
|
+
elsif model_instance.respond_to?(:attributes)
|
|
53
|
+
model_instance.attributes
|
|
54
|
+
end
|
|
55
|
+
document && Embedding.wrap(document, vector_fields)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def vector_fields
|
|
59
|
+
@vector_fields ||= Embedding.vector_fields(model_instance.class)
|
|
75
60
|
end
|
|
76
61
|
|
|
77
62
|
def document_id
|
|
@@ -131,7 +116,7 @@ module Noiseless
|
|
|
131
116
|
end
|
|
132
117
|
end
|
|
133
118
|
|
|
134
|
-
changes
|
|
119
|
+
Embedding.wrap(changes, vector_fields)
|
|
135
120
|
end
|
|
136
121
|
end
|
|
137
122
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Noiseless
|
|
4
|
+
# A dense vector document field. Elasticsearch and OpenSearch send it as a
|
|
5
|
+
# base64 float32 string, which is far smaller and faster to ingest than a
|
|
6
|
+
# JSON float array; every other encoder sees a plain array.
|
|
7
|
+
Embedding = Data.define(:values) do
|
|
8
|
+
def initialize(values:)
|
|
9
|
+
super(values: AST::Vector.coerce_embedding(values))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_a = values
|
|
13
|
+
def as_json(*) = values
|
|
14
|
+
def to_json(*) = values.to_json(*)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class Embedding
|
|
18
|
+
VECTOR_TYPES = %w[dense_vector knn_vector].freeze
|
|
19
|
+
|
|
20
|
+
def self.vector_fields(model_class)
|
|
21
|
+
return [] unless model_class.respond_to?(:mapping) && model_class.mapping
|
|
22
|
+
|
|
23
|
+
properties = MappingDefinitionProcessor.process(model_class.mapping).dig(:mappings, :properties).to_h
|
|
24
|
+
properties.filter_map { |name, config| name.to_s if VECTOR_TYPES.include?(config[:type].to_s) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.wrap(document, fields)
|
|
28
|
+
return document if fields.empty? || !document.respond_to?(:to_h)
|
|
29
|
+
|
|
30
|
+
document.to_h.to_h { |key, value| [key, fields.include?(key.to_s) && value.is_a?(Array) ? new(values: value) : value] }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -33,142 +33,8 @@ module Noiseless
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def add_to_runtime(duration)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# Log subscriber for Rails integration
|
|
42
|
-
class LogSubscriber < ActiveSupport::LogSubscriber
|
|
43
|
-
def search(event)
|
|
44
|
-
return unless logger.debug?
|
|
45
|
-
|
|
46
|
-
indexes = event.payload[:indexes]&.join(", ") || "unknown"
|
|
47
|
-
duration = event.duration.round(2)
|
|
48
|
-
|
|
49
|
-
debug "Noiseless Search (#{duration}ms) indexes=[#{indexes}] #{query_summary(event.payload[:query])}"
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def bulk(event)
|
|
53
|
-
return unless logger.debug?
|
|
54
|
-
|
|
55
|
-
actions_count = event.payload[:actions_count] || 0
|
|
56
|
-
duration = event.duration.round(2)
|
|
57
|
-
|
|
58
|
-
debug "Noiseless Bulk (#{duration}ms) actions=#{actions_count}"
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def index_document(event)
|
|
62
|
-
return unless logger.debug?
|
|
63
|
-
|
|
64
|
-
index = event.payload[:index]
|
|
65
|
-
id = event.payload[:id]
|
|
66
|
-
duration = event.duration.round(2)
|
|
67
|
-
|
|
68
|
-
debug "Noiseless Index Document (#{duration}ms) index=#{index} id=#{id}"
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def update_document(event)
|
|
72
|
-
return unless logger.debug?
|
|
73
|
-
|
|
74
|
-
index = event.payload[:index]
|
|
75
|
-
id = event.payload[:id]
|
|
76
|
-
changes_count = event.payload[:changes_count] || 0
|
|
77
|
-
duration = event.duration.round(2)
|
|
78
|
-
|
|
79
|
-
debug "Noiseless Update Document (#{duration}ms) index=#{index} id=#{id} changes=#{changes_count}"
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def delete_document(event)
|
|
83
|
-
return unless logger.debug?
|
|
84
|
-
|
|
85
|
-
index = event.payload[:index]
|
|
86
|
-
id = event.payload[:id]
|
|
87
|
-
duration = event.duration.round(2)
|
|
88
|
-
|
|
89
|
-
debug "Noiseless Delete Document (#{duration}ms) index=#{index} id=#{id}"
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def create_index(event)
|
|
93
|
-
return unless logger.debug?
|
|
94
|
-
|
|
95
|
-
index = event.payload[:index]
|
|
96
|
-
duration = event.duration.round(2)
|
|
97
|
-
|
|
98
|
-
debug "Noiseless Create Index (#{duration}ms) index=#{index}"
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def delete_index(event)
|
|
102
|
-
return unless logger.debug?
|
|
103
|
-
|
|
104
|
-
index = event.payload[:index]
|
|
105
|
-
duration = event.duration.round(2)
|
|
106
|
-
|
|
107
|
-
debug "Noiseless Delete Index (#{duration}ms) index=#{index}"
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
private
|
|
111
|
-
|
|
112
|
-
def query_summary(query)
|
|
113
|
-
return "empty" unless query.is_a?(Hash)
|
|
114
|
-
|
|
115
|
-
parts = []
|
|
116
|
-
|
|
117
|
-
if query[:query]&.dig(:bool, :must)&.any?
|
|
118
|
-
must_count = query[:query][:bool][:must].size
|
|
119
|
-
parts << "must:#{must_count}"
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
if query[:query]&.dig(:bool, :filter)&.any?
|
|
123
|
-
filter_count = query[:query][:bool][:filter].size
|
|
124
|
-
parts << "filter:#{filter_count}"
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
if query[:sort]&.any?
|
|
128
|
-
sort_count = query[:sort].size
|
|
129
|
-
parts << "sort:#{sort_count}"
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
if query[:from] || query[:size]
|
|
133
|
-
parts << "from:#{query[:from] || 0}"
|
|
134
|
-
parts << "size:#{query[:size] || 20}"
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
parts.join(" ")
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
# Runtime tracking for Rails
|
|
142
|
-
module ControllerRuntime
|
|
143
|
-
extend ActiveSupport::Concern
|
|
144
|
-
|
|
145
|
-
protected
|
|
146
|
-
|
|
147
|
-
def append_info_to_payload(payload)
|
|
148
|
-
super
|
|
149
|
-
payload[:noiseless_runtime] = noiseless_runtime
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def cleanup_view_runtime
|
|
153
|
-
runtime_before_render = noiseless_runtime
|
|
154
|
-
runtime = super
|
|
155
|
-
runtime_after_render = noiseless_runtime
|
|
156
|
-
runtime + runtime_after_render - runtime_before_render
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
private
|
|
160
|
-
|
|
161
|
-
def noiseless_runtime
|
|
162
|
-
Thread.current[:noiseless_runtime] ||= 0
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
module ClassMethods
|
|
166
|
-
def log_process_action(payload)
|
|
167
|
-
messages = super
|
|
168
|
-
runtime = payload[:noiseless_runtime]
|
|
169
|
-
messages << ("Noiseless: %.1fms" % runtime) if runtime&.positive?
|
|
170
|
-
messages
|
|
171
|
-
end
|
|
36
|
+
state = ActiveSupport::IsolatedExecutionState
|
|
37
|
+
state[:noiseless_runtime] = (state[:noiseless_runtime] || 0) + (duration * 1000)
|
|
172
38
|
end
|
|
173
39
|
end
|
|
174
40
|
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Noiseless
|
|
4
|
+
module Introspection
|
|
5
|
+
# Mermaid renderings of an AST and of a query's execution flow.
|
|
6
|
+
class MermaidDiagrams
|
|
7
|
+
def self.generate_flow_diagram(explanation)
|
|
8
|
+
# Create a sequence diagram showing the query execution flow
|
|
9
|
+
diagram = "sequenceDiagram\n".dup
|
|
10
|
+
diagram << " participant Client\n"
|
|
11
|
+
diagram << " participant Adapter\n"
|
|
12
|
+
diagram << " participant Engine\n"
|
|
13
|
+
|
|
14
|
+
explanation[:execution_plan].each do |step|
|
|
15
|
+
diagram << case step[:description]
|
|
16
|
+
when /validate/i
|
|
17
|
+
" Client->>Adapter: #{step[:description]}\n"
|
|
18
|
+
when /convert/i, /format/i
|
|
19
|
+
" Adapter->>Adapter: #{step[:description]}\n"
|
|
20
|
+
when /execute/i, /query/i
|
|
21
|
+
" Adapter->>Engine: #{step[:description]}\n"
|
|
22
|
+
else
|
|
23
|
+
" Engine->>Adapter: #{step[:description]}\n"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
diagram << " Adapter->>Client: Return results\n"
|
|
28
|
+
diagram
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# New methods using your diagram/mermaid gems
|
|
32
|
+
def self.ast_to_mermaid_flowchart(ast_node)
|
|
33
|
+
return "# Mermaid diagrams require 'diagrams' and 'mermaid' gems\n# Add to Gemfile: gem 'diagrams'; gem 'mermaid'" unless DIAGRAMS_AVAILABLE
|
|
34
|
+
|
|
35
|
+
diagram = Diagrams::FlowchartDiagram.new(version: "1.0")
|
|
36
|
+
|
|
37
|
+
# Convert AST structure to flowchart nodes and edges
|
|
38
|
+
add_ast_node_to_flowchart(diagram, ast_node, "root")
|
|
39
|
+
|
|
40
|
+
diagram.to_mermaid
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.ast_to_mermaid_class_diagram(ast_node)
|
|
44
|
+
return "# Mermaid diagrams require 'diagrams' and 'mermaid' gems\n# Add to Gemfile: gem 'diagrams'; gem 'mermaid'" unless DIAGRAMS_AVAILABLE
|
|
45
|
+
|
|
46
|
+
diagram = Diagrams::ClassDiagram.new(version: "1.0")
|
|
47
|
+
|
|
48
|
+
# Create a class representation of the AST structure
|
|
49
|
+
root_class = Diagrams::Elements::ClassEntity.new(
|
|
50
|
+
name: ast_node.class.name.split("::").last,
|
|
51
|
+
attributes: ast_node.instance_variables.map do |var|
|
|
52
|
+
"#{var.to_s.delete('@')}: #{ast_node.instance_variable_get(var).class.name.split('::').last}"
|
|
53
|
+
end,
|
|
54
|
+
methods: ast_node.public_methods(false).map { |method| "+#{method}()" }
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
diagram.add_class(root_class)
|
|
58
|
+
|
|
59
|
+
# Add child nodes as related classes
|
|
60
|
+
add_ast_children_to_class_diagram(diagram, ast_node, root_class.name)
|
|
61
|
+
|
|
62
|
+
diagram.to_mermaid
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.add_ast_node_to_flowchart(diagram, node, node_id)
|
|
66
|
+
# Add current node
|
|
67
|
+
flowchart_node = Diagrams::Elements::Node.new(
|
|
68
|
+
id: node_id,
|
|
69
|
+
label: node.class.name.split("::").last.to_s
|
|
70
|
+
)
|
|
71
|
+
diagram.add_node(flowchart_node)
|
|
72
|
+
|
|
73
|
+
# Add child nodes and connect them
|
|
74
|
+
return unless node.respond_to?(:instance_variables)
|
|
75
|
+
|
|
76
|
+
node.instance_variables.each_with_index do |var, _index|
|
|
77
|
+
child_value = node.instance_variable_get(var)
|
|
78
|
+
|
|
79
|
+
if child_value.is_a?(Noiseless::AST::Node)
|
|
80
|
+
child_id = "#{node_id}_#{var.to_s.delete('@')}"
|
|
81
|
+
add_ast_node_to_flowchart(diagram, child_value, child_id)
|
|
82
|
+
|
|
83
|
+
edge = Diagrams::Elements::Edge.new(
|
|
84
|
+
source_id: node_id,
|
|
85
|
+
target_id: child_id,
|
|
86
|
+
label: var.to_s.delete("@")
|
|
87
|
+
)
|
|
88
|
+
diagram.add_edge(edge)
|
|
89
|
+
elsif child_value.is_a?(Array) && child_value.any?(Noiseless::AST::Node)
|
|
90
|
+
child_value.each_with_index do |item, item_index|
|
|
91
|
+
next unless item.is_a?(Noiseless::AST::Node)
|
|
92
|
+
|
|
93
|
+
child_id = "#{node_id}_#{var.to_s.delete('@')}_#{item_index}"
|
|
94
|
+
add_ast_node_to_flowchart(diagram, item, child_id)
|
|
95
|
+
|
|
96
|
+
edge = Diagrams::Elements::Edge.new(
|
|
97
|
+
source_id: node_id,
|
|
98
|
+
target_id: child_id,
|
|
99
|
+
label: "#{var.to_s.delete('@')}[#{item_index}]"
|
|
100
|
+
)
|
|
101
|
+
diagram.add_edge(edge)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.add_ast_children_to_class_diagram(diagram, node, parent_class_name)
|
|
108
|
+
return unless node.respond_to?(:instance_variables)
|
|
109
|
+
|
|
110
|
+
node.instance_variables.each do |var|
|
|
111
|
+
child_value = node.instance_variable_get(var)
|
|
112
|
+
|
|
113
|
+
next unless child_value.is_a?(Noiseless::AST::Node)
|
|
114
|
+
|
|
115
|
+
child_class_name = child_value.class.name.split("::").last
|
|
116
|
+
|
|
117
|
+
# Add child class if not already added
|
|
118
|
+
unless diagram.classes.any? { |c| c.name == child_class_name }
|
|
119
|
+
child_class = Diagrams::Elements::ClassEntity.new(
|
|
120
|
+
name: child_class_name,
|
|
121
|
+
attributes: child_value.instance_variables.map do |cv|
|
|
122
|
+
"#{cv.to_s.delete('@')}: #{child_value.instance_variable_get(cv).class.name.split('::').last}"
|
|
123
|
+
end
|
|
124
|
+
)
|
|
125
|
+
diagram.add_class(child_class)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Add relationship
|
|
129
|
+
relationship = Diagrams::Elements::Relationship.new(
|
|
130
|
+
source_class_name: parent_class_name,
|
|
131
|
+
target_class_name: child_class_name,
|
|
132
|
+
type: "composition",
|
|
133
|
+
label: var.to_s.delete("@")
|
|
134
|
+
)
|
|
135
|
+
diagram.add_relationship(relationship)
|
|
136
|
+
|
|
137
|
+
# Recursively add children
|
|
138
|
+
add_ast_children_to_class_diagram(diagram, child_value, child_class_name)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|