dbwatcher 1.0.0 → 1.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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +81 -210
  3. data/app/assets/config/dbwatcher_manifest.js +15 -0
  4. data/app/assets/javascripts/dbwatcher/alpine_registrations.js +39 -0
  5. data/app/assets/javascripts/dbwatcher/auto_init.js +23 -0
  6. data/app/assets/javascripts/dbwatcher/components/base.js +141 -0
  7. data/app/assets/javascripts/dbwatcher/components/changes_table_hybrid.js +1008 -0
  8. data/app/assets/javascripts/dbwatcher/components/diagrams.js +449 -0
  9. data/app/assets/javascripts/dbwatcher/components/summary.js +234 -0
  10. data/app/assets/javascripts/dbwatcher/core/alpine_store.js +138 -0
  11. data/app/assets/javascripts/dbwatcher/core/api_client.js +162 -0
  12. data/app/assets/javascripts/dbwatcher/core/component_loader.js +70 -0
  13. data/app/assets/javascripts/dbwatcher/core/component_registry.js +94 -0
  14. data/app/assets/javascripts/dbwatcher/dbwatcher.js +120 -0
  15. data/app/assets/javascripts/dbwatcher/services/mermaid.js +315 -0
  16. data/app/assets/javascripts/dbwatcher/services/mermaid_service.js +199 -0
  17. data/app/assets/javascripts/dbwatcher/vendor/date-fns-browser.js +99 -0
  18. data/app/assets/javascripts/dbwatcher/vendor/lodash.min.js +140 -0
  19. data/app/assets/javascripts/dbwatcher/vendor/tabulator.min.js +3 -0
  20. data/app/assets/stylesheets/dbwatcher/application.css +423 -0
  21. data/app/assets/stylesheets/dbwatcher/application.scss +15 -0
  22. data/app/assets/stylesheets/dbwatcher/components/_badges.scss +38 -0
  23. data/app/assets/stylesheets/dbwatcher/components/_compact_table.scss +162 -0
  24. data/app/assets/stylesheets/dbwatcher/components/_diagrams.scss +51 -0
  25. data/app/assets/stylesheets/dbwatcher/components/_forms.scss +27 -0
  26. data/app/assets/stylesheets/dbwatcher/components/_navigation.scss +55 -0
  27. data/app/assets/stylesheets/dbwatcher/core/_base.scss +34 -0
  28. data/app/assets/stylesheets/dbwatcher/core/_variables.scss +47 -0
  29. data/app/assets/stylesheets/dbwatcher/vendor/tabulator.min.css +2 -0
  30. data/app/controllers/dbwatcher/api/v1/sessions_controller.rb +64 -0
  31. data/app/controllers/dbwatcher/base_controller.rb +8 -2
  32. data/app/controllers/dbwatcher/dashboard_controller.rb +8 -0
  33. data/app/controllers/dbwatcher/sessions_controller.rb +25 -10
  34. data/app/helpers/dbwatcher/component_helper.rb +29 -0
  35. data/app/helpers/dbwatcher/diagram_helper.rb +110 -0
  36. data/app/helpers/dbwatcher/session_helper.rb +3 -2
  37. data/app/views/dbwatcher/sessions/_changes_tab.html.erb +265 -0
  38. data/app/views/dbwatcher/sessions/_diagrams_tab.html.erb +166 -0
  39. data/app/views/dbwatcher/sessions/_session_header.html.erb +11 -0
  40. data/app/views/dbwatcher/sessions/_summary_tab.html.erb +88 -0
  41. data/app/views/dbwatcher/sessions/_tab_navigation.html.erb +12 -0
  42. data/app/views/dbwatcher/sessions/changes.html.erb +21 -0
  43. data/app/views/dbwatcher/sessions/components/changes/_filters.html.erb +44 -0
  44. data/app/views/dbwatcher/sessions/components/changes/_table_list.html.erb +96 -0
  45. data/app/views/dbwatcher/sessions/diagrams.html.erb +21 -0
  46. data/app/views/dbwatcher/sessions/index.html.erb +14 -10
  47. data/app/views/dbwatcher/sessions/shared/_layout.html.erb +8 -0
  48. data/app/views/dbwatcher/sessions/shared/_navigation.html.erb +35 -0
  49. data/app/views/dbwatcher/sessions/shared/_session_header.html.erb +25 -0
  50. data/app/views/dbwatcher/sessions/show.html.erb +3 -346
  51. data/app/views/dbwatcher/sessions/summary.html.erb +21 -0
  52. data/app/views/layouts/dbwatcher/application.html.erb +125 -247
  53. data/bin/compile_scss +49 -0
  54. data/config/routes.rb +26 -0
  55. data/lib/dbwatcher/configuration.rb +102 -8
  56. data/lib/dbwatcher/engine.rb +17 -7
  57. data/lib/dbwatcher/services/analyzers/session_data_processor.rb +98 -0
  58. data/lib/dbwatcher/services/analyzers/table_summary_builder.rb +202 -0
  59. data/lib/dbwatcher/services/api/base_api_service.rb +100 -0
  60. data/lib/dbwatcher/services/api/changes_data_service.rb +112 -0
  61. data/lib/dbwatcher/services/api/diagram_data_service.rb +145 -0
  62. data/lib/dbwatcher/services/api/summary_data_service.rb +158 -0
  63. data/lib/dbwatcher/services/base_service.rb +64 -0
  64. data/lib/dbwatcher/services/diagram_analyzers/base_analyzer.rb +162 -0
  65. data/lib/dbwatcher/services/diagram_analyzers/foreign_key_analyzer.rb +354 -0
  66. data/lib/dbwatcher/services/diagram_analyzers/inferred_relationship_analyzer.rb +502 -0
  67. data/lib/dbwatcher/services/diagram_analyzers/model_association_analyzer.rb +564 -0
  68. data/lib/dbwatcher/services/diagram_data/attribute.rb +154 -0
  69. data/lib/dbwatcher/services/diagram_data/dataset.rb +278 -0
  70. data/lib/dbwatcher/services/diagram_data/entity.rb +180 -0
  71. data/lib/dbwatcher/services/diagram_data/relationship.rb +188 -0
  72. data/lib/dbwatcher/services/diagram_data/relationship_params.rb +55 -0
  73. data/lib/dbwatcher/services/diagram_data.rb +65 -0
  74. data/lib/dbwatcher/services/diagram_error_handler.rb +239 -0
  75. data/lib/dbwatcher/services/diagram_generator.rb +154 -0
  76. data/lib/dbwatcher/services/diagram_strategies/base_diagram_strategy.rb +149 -0
  77. data/lib/dbwatcher/services/diagram_strategies/class_diagram_strategy.rb +49 -0
  78. data/lib/dbwatcher/services/diagram_strategies/erd_diagram_strategy.rb +52 -0
  79. data/lib/dbwatcher/services/diagram_strategies/flowchart_diagram_strategy.rb +52 -0
  80. data/lib/dbwatcher/services/diagram_system.rb +69 -0
  81. data/lib/dbwatcher/services/diagram_type_registry.rb +164 -0
  82. data/lib/dbwatcher/services/mermaid_syntax/base_builder.rb +127 -0
  83. data/lib/dbwatcher/services/mermaid_syntax/cardinality_mapper.rb +90 -0
  84. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_builder.rb +136 -0
  85. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_helper.rb +46 -0
  86. data/lib/dbwatcher/services/mermaid_syntax/erd_builder.rb +116 -0
  87. data/lib/dbwatcher/services/mermaid_syntax/flowchart_builder.rb +109 -0
  88. data/lib/dbwatcher/services/mermaid_syntax/sanitizer.rb +102 -0
  89. data/lib/dbwatcher/services/mermaid_syntax_builder.rb +155 -0
  90. data/lib/dbwatcher/storage/api/concerns/table_analyzer.rb +15 -128
  91. data/lib/dbwatcher/storage/api/session_api.rb +47 -0
  92. data/lib/dbwatcher/storage/base_storage.rb +7 -0
  93. data/lib/dbwatcher/version.rb +1 -1
  94. data/lib/dbwatcher.rb +58 -1
  95. metadata +94 -2
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Services
5
+ module DiagramData
6
+ # Attribute representing a property of an entity
7
+ #
8
+ # This class provides a standardized representation for entity attributes
9
+ # (columns, fields, properties) with consistent validation and serialization.
10
+ #
11
+ # @example
12
+ # attribute = Attribute.new(
13
+ # name: "email",
14
+ # type: "string",
15
+ # nullable: false,
16
+ # default: nil,
17
+ # metadata: { unique: true }
18
+ # )
19
+ # attribute.valid? # => true
20
+ # attribute.to_h # => { name: "email", type: "string", ... }
21
+ class Attribute
22
+ attr_accessor :name, :type, :nullable, :default, :metadata
23
+
24
+ # Initialize attribute
25
+ #
26
+ # @param name [String] attribute name
27
+ # @param type [String] attribute data type
28
+ # @param nullable [Boolean] whether attribute can be null
29
+ # @param default [Object] default value
30
+ # @param metadata [Hash] additional type-specific information
31
+ def initialize(name:, type: nil, nullable: true, default: nil, metadata: {})
32
+ @name = name.to_s
33
+ @type = type.to_s
34
+ @nullable = nullable == true
35
+ @default = default
36
+ @metadata = metadata.is_a?(Hash) ? metadata : {}
37
+ end
38
+
39
+ # Check if attribute is valid
40
+ #
41
+ # @return [Boolean] true if attribute has required fields
42
+ def valid?
43
+ validation_errors.empty?
44
+ end
45
+
46
+ # Get validation errors
47
+ #
48
+ # @return [Array<String>] array of validation error messages
49
+ def validation_errors
50
+ errors = []
51
+ errors << "Name cannot be blank" if name.nil? || name.to_s.strip.empty?
52
+ errors << "Metadata must be a Hash" unless metadata.is_a?(Hash)
53
+ errors
54
+ end
55
+
56
+ # Check if attribute is a primary key
57
+ #
58
+ # @return [Boolean] true if attribute is a primary key
59
+ def primary_key?
60
+ metadata[:primary_key] == true
61
+ end
62
+
63
+ # Check if attribute is a foreign key
64
+ #
65
+ # @return [Boolean] true if attribute is a foreign key
66
+ def foreign_key?
67
+ metadata[:foreign_key] == true || name.to_s.end_with?("_id")
68
+ end
69
+
70
+ # Serialize attribute to hash
71
+ #
72
+ # @return [Hash] serialized attribute data
73
+ def to_h
74
+ {
75
+ name: name,
76
+ type: type,
77
+ nullable: nullable,
78
+ default: default,
79
+ metadata: metadata
80
+ }
81
+ end
82
+
83
+ # Serialize attribute to JSON
84
+ #
85
+ # @return [String] JSON representation
86
+ def to_json(*args)
87
+ to_h.to_json(*args)
88
+ end
89
+
90
+ # Create attribute from hash
91
+ #
92
+ # @param hash [Hash] attribute data
93
+ # @return [Attribute] new attribute instance
94
+ def self.from_h(hash)
95
+ # Convert string keys to symbols for consistent access
96
+ hash = hash.transform_keys(&:to_sym) if hash.keys.first.is_a?(String)
97
+
98
+ # Use fetch with default values to handle missing fields
99
+ new(
100
+ name: hash[:name],
101
+ type: hash[:type],
102
+ nullable: hash.key?(:nullable) ? hash[:nullable] : true,
103
+ default: hash[:default],
104
+ metadata: hash[:metadata] || {}
105
+ )
106
+ end
107
+
108
+ # Create attribute from JSON
109
+ #
110
+ # @param json [String] JSON string
111
+ # @return [Attribute] new attribute instance
112
+ def self.from_json(json)
113
+ from_h(JSON.parse(json))
114
+ end
115
+
116
+ # Check equality with another attribute
117
+ #
118
+ # @param other [Attribute] other attribute to compare
119
+ # @return [Boolean] true if attributes are equal
120
+ def ==(other)
121
+ return false unless other.is_a?(Attribute)
122
+
123
+ name == other.name &&
124
+ type == other.type &&
125
+ nullable == other.nullable &&
126
+ default == other.default &&
127
+ metadata == other.metadata
128
+ end
129
+
130
+ # Generate hash code for attribute
131
+ #
132
+ # @return [Integer] hash code
133
+ def hash
134
+ [name, type, nullable, default, metadata].hash
135
+ end
136
+
137
+ # String representation of attribute
138
+ #
139
+ # @return [String] string representation
140
+ def to_s
141
+ "#{self.class.name}(name: #{name}, type: #{type}, nullable: #{nullable})"
142
+ end
143
+
144
+ # Detailed string representation
145
+ #
146
+ # @return [String] detailed string representation
147
+ def inspect
148
+ "#{self.class.name}(name: #{name.inspect}, type: #{type.inspect}, " \
149
+ "nullable: #{nullable.inspect}, default: #{default.inspect}, metadata: #{metadata.inspect})"
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Services
5
+ module DiagramData
6
+ # Complete dataset for diagram generation
7
+ #
8
+ # This class serves as a container for all diagram data including entities
9
+ # and relationships, with validation and management capabilities.
10
+ #
11
+ # @example
12
+ # dataset = Dataset.new
13
+ # dataset.add_entity(Entity.new(id: "users", name: "User", type: "table"))
14
+ # dataset.add_entity(Entity.new(id: "orders", name: "Order", type: "table"))
15
+ # dataset.add_relationship(Relationship.new(
16
+ # source_id: "users", target_id: "orders", type: "has_many"
17
+ # ))
18
+ # dataset.valid? # => true
19
+ # dataset.to_h # => complete dataset hash
20
+ class Dataset
21
+ attr_reader :entities, :relationships, :metadata
22
+
23
+ # Initialize empty dataset
24
+ #
25
+ # @param metadata [Hash] optional dataset-level metadata
26
+ def initialize(metadata: {})
27
+ @entities = {}
28
+ @relationships = []
29
+ @metadata = metadata.is_a?(Hash) ? metadata : {}
30
+ end
31
+
32
+ # Add entity to dataset
33
+ #
34
+ # @param entity [Entity] entity to add
35
+ # @return [Entity] the added entity
36
+ # @raise [ArgumentError] if entity is invalid
37
+ def add_entity(entity)
38
+ raise ArgumentError, "Entity must be an Entity instance" unless entity.is_a?(Entity)
39
+
40
+ raise ArgumentError, "Entity is invalid: #{entity.validation_errors.join(", ")}" unless entity.valid?
41
+
42
+ @entities[entity.id] = entity
43
+ entity
44
+ end
45
+
46
+ # Add relationship to dataset
47
+ #
48
+ # @param relationship [Relationship] relationship to add
49
+ # @return [Relationship] the added relationship
50
+ # @raise [ArgumentError] if relationship is invalid
51
+ def add_relationship(relationship)
52
+ raise ArgumentError, "Relationship must be a Relationship instance" unless relationship.is_a?(Relationship)
53
+
54
+ unless relationship.valid?
55
+ raise ArgumentError, "Relationship is invalid: #{relationship.validation_errors.join(", ")}"
56
+ end
57
+
58
+ @relationships << relationship
59
+ relationship
60
+ end
61
+
62
+ # Get entity by ID
63
+ #
64
+ # @param id [String] entity ID
65
+ # @return [Entity, nil] entity or nil if not found
66
+ def get_entity(id)
67
+ @entities[id.to_s]
68
+ end
69
+
70
+ # Check if entity exists
71
+ #
72
+ # @param id [String] entity ID
73
+ # @return [Boolean] true if entity exists
74
+ def entity?(id)
75
+ @entities.key?(id.to_s)
76
+ end
77
+
78
+ # Remove entity by ID
79
+ #
80
+ # @param id [String] entity ID
81
+ # @return [Entity, nil] removed entity or nil if not found
82
+ def remove_entity(id)
83
+ entity = @entities.delete(id.to_s)
84
+
85
+ # Remove relationships involving this entity
86
+ if entity
87
+ @relationships.reject! do |rel|
88
+ rel.source_id == id.to_s || rel.target_id == id.to_s
89
+ end
90
+ end
91
+
92
+ entity
93
+ end
94
+
95
+ # Remove relationship
96
+ #
97
+ # @param relationship [Relationship] relationship to remove
98
+ # @return [Boolean] true if relationship was removed
99
+ def remove_relationship(relationship)
100
+ !@relationships.delete(relationship).nil?
101
+ end
102
+
103
+ # Get relationships for an entity
104
+ #
105
+ # @param entity_id [String] entity ID
106
+ # @param direction [Symbol] :outgoing, :incoming, or :all
107
+ # @return [Array<Relationship>] filtered relationships
108
+ def relationships_for(entity_id, direction: :all)
109
+ id = entity_id.to_s
110
+
111
+ case direction
112
+ when :outgoing
113
+ @relationships.select { |rel| rel.source_id == id }
114
+ when :incoming
115
+ @relationships.select { |rel| rel.target_id == id }
116
+ when :all
117
+ @relationships.select { |rel| rel.source_id == id || rel.target_id == id }
118
+ else
119
+ raise ArgumentError, "Direction must be :outgoing, :incoming, or :all"
120
+ end
121
+ end
122
+
123
+ # Check if dataset is valid
124
+ #
125
+ # @return [Boolean] true if dataset is valid
126
+ def valid?
127
+ validation_errors.empty?
128
+ end
129
+
130
+ # Get validation errors
131
+ #
132
+ # @return [Array<String>] array of validation error messages
133
+ def validation_errors
134
+ errors = []
135
+
136
+ # Validate all entities
137
+ @entities.each do |id, entity|
138
+ errors << "Entity #{id} is invalid: #{entity.validation_errors.join(", ")}" unless entity.valid?
139
+ end
140
+
141
+ # Validate all relationships
142
+ @relationships.each_with_index do |relationship, index|
143
+ unless relationship.valid?
144
+ errors << "Relationship #{index} is invalid: #{relationship.validation_errors.join(", ")}"
145
+ end
146
+
147
+ # Check that referenced entities exist
148
+ unless entity?(relationship.source_id)
149
+ errors << "Relationship #{index} references non-existent source entity: #{relationship.source_id}"
150
+ end
151
+
152
+ unless entity?(relationship.target_id)
153
+ errors << "Relationship #{index} references non-existent target entity: #{relationship.target_id}"
154
+ end
155
+ end
156
+
157
+ # Validate metadata
158
+ errors << "Metadata must be a Hash" unless @metadata.is_a?(Hash)
159
+
160
+ errors
161
+ end
162
+
163
+ # Get dataset statistics
164
+ #
165
+ # @return [Hash] statistics about the dataset
166
+ def stats
167
+ {
168
+ entity_count: @entities.size,
169
+ relationship_count: @relationships.size,
170
+ entity_types: @entities.values.map(&:type).uniq.sort,
171
+ relationship_types: @relationships.map(&:type).uniq.sort,
172
+ isolated_entities: isolated_entities.map(&:id),
173
+ connected_entities: connected_entities.map(&:id)
174
+ }
175
+ end
176
+
177
+ # Get entities with no relationships
178
+ #
179
+ # @return [Array<Entity>] isolated entities
180
+ def isolated_entities
181
+ connected_ids = (@relationships.map(&:source_id) + @relationships.map(&:target_id)).uniq
182
+ @entities.values.reject { |entity| connected_ids.include?(entity.id) }
183
+ end
184
+
185
+ # Get entities with at least one relationship
186
+ #
187
+ # @return [Array<Entity>] connected entities
188
+ def connected_entities
189
+ connected_ids = (@relationships.map(&:source_id) + @relationships.map(&:target_id)).uniq
190
+ @entities.values.select { |entity| connected_ids.include?(entity.id) }
191
+ end
192
+
193
+ # Check if dataset is empty
194
+ #
195
+ # @return [Boolean] true if no entities or relationships
196
+ def empty?
197
+ @entities.empty? && @relationships.empty?
198
+ end
199
+
200
+ # Clear all data from dataset
201
+ #
202
+ # @return [self] for method chaining
203
+ def clear
204
+ @entities.clear
205
+ @relationships.clear
206
+ @metadata.clear
207
+ self
208
+ end
209
+
210
+ # Serialize dataset to hash
211
+ #
212
+ # @return [Hash] serialized dataset
213
+ def to_h
214
+ {
215
+ entities: @entities.transform_values(&:to_h),
216
+ relationships: @relationships.map(&:to_h),
217
+ metadata: @metadata,
218
+ stats: stats
219
+ }
220
+ end
221
+
222
+ # Serialize dataset to JSON
223
+ #
224
+ # @return [String] JSON representation
225
+ def to_json(*args)
226
+ to_h.to_json(*args)
227
+ end
228
+
229
+ # Create dataset from hash
230
+ #
231
+ # @param hash [Hash] dataset data
232
+ # @return [Dataset] new dataset instance
233
+ def self.from_h(hash)
234
+ dataset = new(metadata: hash[:metadata] || hash["metadata"] || {})
235
+
236
+ # Load entities
237
+ entities_data = hash[:entities] || hash["entities"] || {}
238
+ entities_data.each_value do |entity_data|
239
+ entity = Entity.from_h(entity_data)
240
+ dataset.add_entity(entity)
241
+ end
242
+
243
+ # Load relationships
244
+ relationships_data = hash[:relationships] || hash["relationships"] || []
245
+ relationships_data.each do |relationship_data|
246
+ relationship = Relationship.from_h(relationship_data)
247
+ dataset.add_relationship(relationship)
248
+ end
249
+
250
+ dataset
251
+ end
252
+
253
+ # Create dataset from JSON
254
+ #
255
+ # @param json [String] JSON string
256
+ # @return [Dataset] new dataset instance
257
+ def self.from_json(json)
258
+ from_h(JSON.parse(json))
259
+ end
260
+
261
+ # String representation of dataset
262
+ #
263
+ # @return [String] string representation
264
+ def to_s
265
+ "#{self.class.name}(entities: #{@entities.size}, relationships: #{@relationships.size})"
266
+ end
267
+
268
+ # Detailed string representation
269
+ #
270
+ # @return [String] detailed string representation
271
+ def inspect
272
+ "#{self.class.name}(entities: #{@entities.size}, relationships: #{@relationships.size}, " \
273
+ "metadata: #{@metadata.inspect})"
274
+ end
275
+ end
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Services
5
+ module DiagramData
6
+ # Entity representing a node in any diagram
7
+ #
8
+ # This class provides a standardized representation for all diagram entities
9
+ # (nodes, tables, models, etc.) with consistent validation and serialization.
10
+ #
11
+ # @example
12
+ # entity = Entity.new(
13
+ # id: "users",
14
+ # name: "User",
15
+ # type: "table",
16
+ # attributes: [
17
+ # Attribute.new(name: "id", type: "integer", nullable: false, metadata: { primary_key: true })
18
+ # ],
19
+ # metadata: { columns: ["id", "name", "email"] }
20
+ # )
21
+ # entity.valid? # => true
22
+ # entity.to_h # => { id: "users", name: "User", ... }
23
+ class Entity
24
+ attr_accessor :id, :name, :type, :attributes, :metadata
25
+
26
+ # Initialize entity
27
+ #
28
+ # @param id [String] unique identifier for the entity
29
+ # @param name [String] display name for the entity
30
+ # @param type [String] entity type (table, model, etc.)
31
+ # @param attributes [Array<Attribute>] entity attributes/properties
32
+ # @param metadata [Hash] additional type-specific information
33
+ def initialize(id:, name:, type: "default", attributes: [], metadata: {})
34
+ @id = id.to_s
35
+ @name = name.to_s
36
+ @type = type.to_s
37
+ @attributes = attributes.is_a?(Array) ? attributes : []
38
+ @metadata = metadata.is_a?(Hash) ? metadata : {}
39
+ end
40
+
41
+ # Check if entity is valid
42
+ #
43
+ # @return [Boolean] true if entity has required fields
44
+ def valid?
45
+ validation_errors.empty?
46
+ end
47
+
48
+ # Get validation errors
49
+ #
50
+ # @return [Array<String>] array of validation error messages
51
+ def validation_errors
52
+ errors = []
53
+ errors << "ID cannot be blank" if id.nil? || id.to_s.strip.empty?
54
+ errors << "Name cannot be blank" if name.nil? || name.to_s.strip.empty?
55
+ errors << "Type cannot be blank" if type.nil? || type.to_s.strip.empty?
56
+ errors << "Attributes must be an Array" unless attributes.is_a?(Array)
57
+ errors << "Metadata must be a Hash" unless metadata.is_a?(Hash)
58
+
59
+ # Validate all attributes
60
+ attributes.each_with_index do |attribute, index|
61
+ errors << "Attribute at index #{index} is invalid" unless attribute.is_a?(Attribute) && attribute.valid?
62
+ end
63
+
64
+ errors
65
+ end
66
+
67
+ # Add an attribute to the entity
68
+ #
69
+ # @param attribute [Attribute] attribute to add
70
+ # @return [Attribute] the added attribute
71
+ # @raise [ArgumentError] if attribute is invalid
72
+ def add_attribute(attribute)
73
+ raise ArgumentError, "Attribute must be an Attribute instance" unless attribute.is_a?(Attribute)
74
+ raise ArgumentError, "Attribute is invalid: #{attribute.validation_errors.join(", ")}" unless attribute.valid?
75
+
76
+ @attributes << attribute
77
+ attribute
78
+ end
79
+
80
+ # Get primary key attributes
81
+ #
82
+ # @return [Array<Attribute>] primary key attributes
83
+ def primary_key_attributes
84
+ attributes.select(&:primary_key?)
85
+ end
86
+
87
+ # Get foreign key attributes
88
+ #
89
+ # @return [Array<Attribute>] foreign key attributes
90
+ def foreign_key_attributes
91
+ attributes.select(&:foreign_key?)
92
+ end
93
+
94
+ # Serialize entity to hash
95
+ #
96
+ # @return [Hash] serialized entity data
97
+ def to_h
98
+ {
99
+ id: id,
100
+ name: name,
101
+ type: type,
102
+ attributes: attributes.map(&:to_h),
103
+ metadata: metadata
104
+ }
105
+ end
106
+
107
+ # Serialize entity to JSON
108
+ #
109
+ # @return [String] JSON representation
110
+ def to_json(*args)
111
+ to_h.to_json(*args)
112
+ end
113
+
114
+ # Create entity from hash
115
+ #
116
+ # @param hash [Hash] entity data
117
+ # @return [Entity] new entity instance
118
+ def self.from_h(hash)
119
+ attrs = []
120
+ if hash[:attributes] || hash["attributes"]
121
+ attr_data = hash[:attributes] || hash["attributes"]
122
+ attrs = attr_data.map { |attr| Attribute.from_h(attr) }
123
+ end
124
+
125
+ new(
126
+ id: hash[:id] || hash["id"],
127
+ name: hash[:name] || hash["name"],
128
+ type: hash[:type] || hash["type"] || "default",
129
+ attributes: attrs,
130
+ metadata: hash[:metadata] || hash["metadata"] || {}
131
+ )
132
+ end
133
+
134
+ # Create entity from JSON
135
+ #
136
+ # @param json [String] JSON string
137
+ # @return [Entity] new entity instance
138
+ def self.from_json(json)
139
+ from_h(JSON.parse(json))
140
+ end
141
+
142
+ # Check equality with another entity
143
+ #
144
+ # @param other [Entity] other entity to compare
145
+ # @return [Boolean] true if entities are equal
146
+ def ==(other)
147
+ return false unless other.is_a?(Entity)
148
+
149
+ id == other.id &&
150
+ name == other.name &&
151
+ type == other.type &&
152
+ attributes == other.attributes &&
153
+ metadata == other.metadata
154
+ end
155
+
156
+ # Generate hash code for entity
157
+ #
158
+ # @return [Integer] hash code
159
+ def hash
160
+ [id, name, type, attributes, metadata].hash
161
+ end
162
+
163
+ # String representation of entity
164
+ #
165
+ # @return [String] string representation
166
+ def to_s
167
+ "#{self.class.name}(id: #{id}, name: #{name}, type: #{type})"
168
+ end
169
+
170
+ # Detailed string representation
171
+ #
172
+ # @return [String] detailed string representation
173
+ def inspect
174
+ "#{self.class.name}(id: #{id.inspect}, name: #{name.inspect}, " \
175
+ "type: #{type.inspect}, attributes: #{attributes.length}, metadata: #{metadata.inspect})"
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end