aven 0.0.3

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 (159) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +35 -0
  4. data/Rakefile +19 -0
  5. data/app/assets/stylesheets/aven/application.css +14 -0
  6. data/app/assets/stylesheets/aven/application.tailwind.css +7 -0
  7. data/app/assets/stylesheets/aven/tailwind.css +224 -0
  8. data/app/channels/aven/chat/thread_channel.rb +39 -0
  9. data/app/components/aven/application_view_component.rb +15 -0
  10. data/app/components/aven/views/admin/dashboard/index/component.html.erb +1 -0
  11. data/app/components/aven/views/admin/dashboard/index/component.rb +5 -0
  12. data/app/components/aven/views/articles/edit/component.html.erb +14 -0
  13. data/app/components/aven/views/articles/edit/component.rb +14 -0
  14. data/app/components/aven/views/articles/form/component.html.erb +45 -0
  15. data/app/components/aven/views/articles/form/component.rb +27 -0
  16. data/app/components/aven/views/articles/index/component.html.erb +93 -0
  17. data/app/components/aven/views/articles/index/component.rb +29 -0
  18. data/app/components/aven/views/articles/new/component.html.erb +13 -0
  19. data/app/components/aven/views/articles/new/component.rb +14 -0
  20. data/app/components/aven/views/articles/show/component.html.erb +110 -0
  21. data/app/components/aven/views/articles/show/component.rb +34 -0
  22. data/app/components/aven/views/oauth/error/component.html.erb +44 -0
  23. data/app/components/aven/views/oauth/error/component.rb +30 -0
  24. data/app/components/aven/views/static/index/component.html.erb +17 -0
  25. data/app/components/aven/views/static/index/component.rb +16 -0
  26. data/app/components/aven/views/static/index/controller.js +7 -0
  27. data/app/controllers/aven/admin/base.rb +16 -0
  28. data/app/controllers/aven/admin/dashboard_controller.rb +9 -0
  29. data/app/controllers/aven/agentic/agents_controller.rb +56 -0
  30. data/app/controllers/aven/agentic/documents_controller.rb +51 -0
  31. data/app/controllers/aven/agentic/mcp_controller.rb +124 -0
  32. data/app/controllers/aven/agentic/tools_controller.rb +37 -0
  33. data/app/controllers/aven/ai/text_controller.rb +41 -0
  34. data/app/controllers/aven/application_controller.rb +27 -0
  35. data/app/controllers/aven/articles_controller.rb +114 -0
  36. data/app/controllers/aven/auth_controller.rb +12 -0
  37. data/app/controllers/aven/chat/threads_controller.rb +67 -0
  38. data/app/controllers/aven/oauth/auth0_controller.rb +84 -0
  39. data/app/controllers/aven/oauth/base_controller.rb +183 -0
  40. data/app/controllers/aven/oauth/documentation/auth0.md +387 -0
  41. data/app/controllers/aven/oauth/documentation/entra_id.md +608 -0
  42. data/app/controllers/aven/oauth/documentation/github.md +329 -0
  43. data/app/controllers/aven/oauth/documentation/google.md +253 -0
  44. data/app/controllers/aven/oauth/entra_id_controller.rb +92 -0
  45. data/app/controllers/aven/oauth/github_controller.rb +91 -0
  46. data/app/controllers/aven/oauth/google_controller.rb +64 -0
  47. data/app/controllers/aven/static_controller.rb +7 -0
  48. data/app/controllers/aven/tags_controller.rb +44 -0
  49. data/app/controllers/aven/workspaces_controller.rb +20 -0
  50. data/app/controllers/concerns/aven/authentication.rb +49 -0
  51. data/app/controllers/concerns/aven/controller_helpers.rb +38 -0
  52. data/app/helpers/aven/application_helper.rb +16 -0
  53. data/app/javascript/aven/application.js +3 -0
  54. data/app/javascript/aven/controllers/application.js +5 -0
  55. data/app/javascript/aven/controllers/index.js +11 -0
  56. data/app/jobs/aven/agentic/document_embedding_job.rb +28 -0
  57. data/app/jobs/aven/agentic/document_ocr_job.rb +28 -0
  58. data/app/jobs/aven/application_job.rb +4 -0
  59. data/app/jobs/aven/chat/calculate_cost_job.rb +26 -0
  60. data/app/jobs/aven/chat/run_job.rb +27 -0
  61. data/app/mailers/aven/application_mailer.rb +6 -0
  62. data/app/models/aven/agentic/agent.rb +76 -0
  63. data/app/models/aven/agentic/agent_document.rb +37 -0
  64. data/app/models/aven/agentic/agent_tool.rb +37 -0
  65. data/app/models/aven/agentic/document.rb +162 -0
  66. data/app/models/aven/agentic/document_embedding.rb +39 -0
  67. data/app/models/aven/agentic/tool.rb +106 -0
  68. data/app/models/aven/agentic/tool_parameter.rb +56 -0
  69. data/app/models/aven/application_record.rb +5 -0
  70. data/app/models/aven/article.rb +86 -0
  71. data/app/models/aven/article_attachment.rb +18 -0
  72. data/app/models/aven/article_relationship.rb +26 -0
  73. data/app/models/aven/chat/message.rb +135 -0
  74. data/app/models/aven/chat/thread.rb +159 -0
  75. data/app/models/aven/import/entry.rb +45 -0
  76. data/app/models/aven/import/item_link.rb +36 -0
  77. data/app/models/aven/import/processor.rb +123 -0
  78. data/app/models/aven/import.rb +102 -0
  79. data/app/models/aven/item/embed.rb +54 -0
  80. data/app/models/aven/item/embeddable.rb +141 -0
  81. data/app/models/aven/item/linkable.rb +212 -0
  82. data/app/models/aven/item/schema/builder.rb +139 -0
  83. data/app/models/aven/item/schemaed.rb +252 -0
  84. data/app/models/aven/item/schemas/base.rb +108 -0
  85. data/app/models/aven/item.rb +128 -0
  86. data/app/models/aven/item_link.rb +43 -0
  87. data/app/models/aven/item_schema.rb +87 -0
  88. data/app/models/aven/log.rb +66 -0
  89. data/app/models/aven/loggable.rb +20 -0
  90. data/app/models/aven/user.rb +40 -0
  91. data/app/models/aven/workspace.rb +93 -0
  92. data/app/models/aven/workspace_role.rb +46 -0
  93. data/app/models/aven/workspace_user.rb +54 -0
  94. data/app/models/aven/workspace_user_role.rb +38 -0
  95. data/app/models/concerns/aven/agentic/document_embeddable.rb +58 -0
  96. data/app/models/concerns/aven/searchable.rb +61 -0
  97. data/app/services/aven/agentic/dynamic_tool_builder.rb +81 -0
  98. data/app/services/aven/agentic/mcp/adapter.rb +77 -0
  99. data/app/services/aven/agentic/mcp/result_formatter.rb +57 -0
  100. data/app/services/aven/agentic/mcp/server_factory.rb +43 -0
  101. data/app/services/aven/agentic/ocr/base_extractor.rb +39 -0
  102. data/app/services/aven/agentic/ocr/excel_extractor.rb +43 -0
  103. data/app/services/aven/agentic/ocr/image_extractor.rb +22 -0
  104. data/app/services/aven/agentic/ocr/pdf_extractor.rb +48 -0
  105. data/app/services/aven/agentic/ocr/processor.rb +36 -0
  106. data/app/services/aven/agentic/ocr/textract_client.rb +131 -0
  107. data/app/services/aven/agentic/ocr/word_extractor.rb +34 -0
  108. data/app/services/aven/agentic/tool_result_formatter.rb +76 -0
  109. data/app/services/aven/agentic/tools/base.rb +55 -0
  110. data/app/services/aven/agentic/tools/concerns/boolean_filtering.rb +40 -0
  111. data/app/services/aven/agentic/tools/concerns/enum_filtering.rb +47 -0
  112. data/app/services/aven/agentic/tools/concerns/geo_filtering.rb +56 -0
  113. data/app/services/aven/agentic/tools/concerns/range_filtering.rb +51 -0
  114. data/app/services/aven/chat/broadcaster.rb +59 -0
  115. data/app/services/aven/chat/config.rb +93 -0
  116. data/app/services/aven/chat/message_builder.rb +42 -0
  117. data/app/services/aven/chat/orchestrator.rb +69 -0
  118. data/app/services/aven/chat/runner.rb +105 -0
  119. data/app/services/aven/chat/title_generator.rb +61 -0
  120. data/app/services/aven/external/gmail_client.rb +173 -0
  121. data/app/services/aven/external/google_contacts_client.rb +95 -0
  122. data/app/views/layouts/aven/admin.html.erb +16 -0
  123. data/app/views/layouts/aven/application.html.erb +18 -0
  124. data/config/importmap.rb +16 -0
  125. data/config/routes.rb +63 -0
  126. data/db/migrate/20200101000001_create_aven_users.rb +19 -0
  127. data/db/migrate/20200101000002_create_aven_workspaces.rb +14 -0
  128. data/db/migrate/20200101000003_create_aven_workspace_users.rb +12 -0
  129. data/db/migrate/20200101000004_create_aven_workspace_roles.rb +13 -0
  130. data/db/migrate/20200101000005_create_aven_workspace_user_roles.rb +12 -0
  131. data/db/migrate/20200101000006_create_aven_logs.rb +21 -0
  132. data/db/migrate/20200101000009_create_aven_items.rb +17 -0
  133. data/db/migrate/20200101000010_create_aven_item_links.rb +17 -0
  134. data/db/migrate/20200101000011_create_aven_agentic_tools.rb +19 -0
  135. data/db/migrate/20200101000012_create_aven_agentic_tool_parameters.rb +20 -0
  136. data/db/migrate/20200101000013_create_aven_agentic_documents.rb +22 -0
  137. data/db/migrate/20200101000014_create_aven_agentic_document_embeddings.rb +18 -0
  138. data/db/migrate/20200101000015_create_aven_agentic_agents.rb +18 -0
  139. data/db/migrate/20200101000016_create_aven_agentic_agent_tools.rb +13 -0
  140. data/db/migrate/20200101000017_create_aven_agentic_agent_documents.rb +13 -0
  141. data/db/migrate/20200101000018_create_aven_chat_threads.rb +19 -0
  142. data/db/migrate/20200101000019_create_aven_chat_messages.rb +26 -0
  143. data/db/migrate/20200101000020_add_pg_search_support.rb +21 -0
  144. data/db/migrate/20200101000021_create_aven_item_schemas.rb +18 -0
  145. data/db/migrate/20200101000022_create_aven_imports.rb +23 -0
  146. data/db/migrate/20200101000023_create_aven_import_entries.rb +13 -0
  147. data/db/migrate/20200101000024_create_aven_import_item_links.rb +13 -0
  148. data/db/migrate/20200101000025_create_aven_articles.rb +19 -0
  149. data/db/migrate/20200101000026_create_aven_article_attachments.rb +13 -0
  150. data/db/migrate/20200101000027_create_aven_article_relationships.rb +15 -0
  151. data/lib/aven/configuration.rb +87 -0
  152. data/lib/aven/engine.rb +43 -0
  153. data/lib/aven/model/tenant_model.rb +91 -0
  154. data/lib/aven/model.rb +6 -0
  155. data/lib/aven/version.rb +3 -0
  156. data/lib/aven.rb +8 -0
  157. data/lib/tasks/annotate_rb.rake +10 -0
  158. data/lib/tasks/aven_tasks.rake +21 -0
  159. metadata +426 -0
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aven
4
+ module Item::Linkable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ has_many :outgoing_links, class_name: "Aven::ItemLink", foreign_key: :source_id, dependent: :destroy
9
+ has_many :incoming_links, class_name: "Aven::ItemLink", foreign_key: :target_id, dependent: :destroy
10
+
11
+ after_save :persist_pending_links
12
+ end
13
+
14
+ private
15
+
16
+ def persist_pending_links
17
+ persist_pending_link_ids if @_pending_links.present?
18
+ persist_pending_link_attrs if @_pending_link_attrs.present?
19
+
20
+ @_pending_links = nil
21
+ @_pending_link_attrs = nil
22
+ end
23
+
24
+ def persist_pending_link_ids
25
+ @_pending_links.each do |relation_name, ids|
26
+ config = schema_links[relation_name]
27
+ next unless config
28
+
29
+ # Delete existing links for this relation
30
+ Aven::ItemLink.where(source_id: id, relation: relation_name.to_s).delete_all
31
+
32
+ if config[:cardinality] == :many
33
+ Array(ids).each_with_index do |target_id, position|
34
+ next unless Aven::Item.exists?(target_id)
35
+ Aven::ItemLink.create!(source_id: id, target_id:, relation: relation_name.to_s, position:)
36
+ end
37
+ else
38
+ if ids.present? && Aven::Item.exists?(ids)
39
+ Aven::ItemLink.create!(source_id: id, target_id: ids, relation: relation_name.to_s)
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ def persist_pending_link_attrs
46
+ @_pending_link_attrs.each do |relation_name, pending_data|
47
+ config = pending_data[:config]
48
+ attrs_list = pending_data[:attrs]
49
+ target_schema_slug = pending_data[:target_schema_slug]
50
+
51
+ if config[:cardinality] == :many
52
+ persist_many_link_attrs(relation_name, attrs_list, target_schema_slug)
53
+ else
54
+ persist_one_link_attrs(relation_name, attrs_list.first, target_schema_slug)
55
+ end
56
+ end
57
+ end
58
+
59
+ def persist_many_link_attrs(relation_name, attrs_list, target_schema_slug)
60
+ attrs_list.each_with_index do |attrs, position|
61
+ attr_hash = attrs.with_indifferent_access
62
+
63
+ if destroy_link_flag?(attr_hash)
64
+ # Delete the link (not the target item)
65
+ if attr_hash[:id].present?
66
+ Aven::ItemLink.where(source_id: id, target_id: attr_hash[:id], relation: relation_name.to_s).delete_all
67
+ end
68
+ elsif attr_hash[:id].present?
69
+ # Update existing
70
+ target = Aven::Item.find_by(id: attr_hash[:id])
71
+ if target
72
+ update_item_data(target, attr_hash.except(:id, :_destroy))
73
+ # Update position if link exists
74
+ link = Aven::ItemLink.find_by(source_id: id, target_id: target.id, relation: relation_name.to_s)
75
+ link&.update!(position:)
76
+ end
77
+ else
78
+ # Create new
79
+ next if attr_hash.except(:_destroy).blank?
80
+
81
+ data_attrs, nested_attrs = split_attrs(attr_hash)
82
+ target = Aven::Item.create!(
83
+ workspace:,
84
+ schema_slug: target_schema_slug,
85
+ data: data_attrs
86
+ )
87
+ # Apply nested attributes after creation
88
+ apply_nested_attrs(target, nested_attrs)
89
+ Aven::ItemLink.create!(source_id: id, target_id: target.id, relation: relation_name.to_s, position:)
90
+ end
91
+ end
92
+ end
93
+
94
+ def persist_one_link_attrs(relation_name, attrs, target_schema_slug)
95
+ return unless attrs
96
+
97
+ attr_hash = attrs.with_indifferent_access
98
+
99
+ if destroy_link_flag?(attr_hash)
100
+ # Delete the link
101
+ Aven::ItemLink.where(source_id: id, relation: relation_name.to_s).delete_all
102
+ elsif attr_hash[:id].present?
103
+ # Update existing
104
+ target = Aven::Item.find_by(id: attr_hash[:id])
105
+ update_item_data(target, attr_hash.except(:id, :_destroy)) if target
106
+ else
107
+ # Create new - first remove existing link
108
+ Aven::ItemLink.where(source_id: id, relation: relation_name.to_s).delete_all
109
+ return if attr_hash.except(:_destroy).blank?
110
+
111
+ data_attrs, nested_attrs = split_attrs(attr_hash)
112
+ target = Aven::Item.create!(
113
+ workspace:,
114
+ schema_slug: target_schema_slug,
115
+ data: data_attrs
116
+ )
117
+ # Apply nested attributes after creation
118
+ apply_nested_attrs(target, nested_attrs)
119
+ Aven::ItemLink.create!(source_id: id, target_id: target.id, relation: relation_name.to_s)
120
+ end
121
+ end
122
+
123
+ def update_item_data(item, attrs)
124
+ attrs.each do |key, value|
125
+ key_str = key.to_s
126
+ # Handle nested attributes (e.g., notes_attributes)
127
+ if key_str.end_with?("_attributes")
128
+ item.send("#{key_str}=", value)
129
+ else
130
+ item.data[key_str] = value
131
+ end
132
+ end
133
+ item.save!
134
+ end
135
+
136
+ def split_attrs(attrs)
137
+ # Split attrs into data fields and nested *_attributes
138
+ data_attrs = {}
139
+ nested_attrs = {}
140
+
141
+ attrs.each do |key, value|
142
+ key_str = key.to_s
143
+ next if key_str == "id" || key_str == "_destroy"
144
+
145
+ if key_str.end_with?("_attributes")
146
+ nested_attrs[key_str] = value
147
+ else
148
+ data_attrs[key_str] = value
149
+ end
150
+ end
151
+
152
+ [data_attrs, nested_attrs]
153
+ end
154
+
155
+ def apply_nested_attrs(item, nested_attrs)
156
+ return if nested_attrs.blank?
157
+
158
+ nested_attrs.each do |key, value|
159
+ item.send("#{key}=", value)
160
+ end
161
+ item.save!
162
+ end
163
+
164
+ def clean_link_attrs(attrs)
165
+ # Only return data fields (exclude id, _destroy, and *_attributes)
166
+ result = {}
167
+ attrs.each do |key, value|
168
+ key_str = key.to_s
169
+ next if key_str == "id" || key_str == "_destroy" || key_str.end_with?("_attributes")
170
+ result[key_str] = value
171
+ end
172
+ result
173
+ end
174
+
175
+ def destroy_link_flag?(attrs)
176
+ val = attrs[:_destroy] || attrs["_destroy"]
177
+ val == "1" || val == true
178
+ end
179
+
180
+ def process_link_attributes(name, attrs)
181
+ config = schema_links[name]
182
+ return unless config
183
+
184
+ # Derive target schema_slug from link name (e.g., :notes => "note", :company => "company")
185
+ target_schema_slug = name.to_s.singularize
186
+
187
+ @_pending_link_attrs ||= {}
188
+ @_pending_link_attrs[name] = {
189
+ config:,
190
+ attrs: normalize_link_attrs(attrs, config[:cardinality]),
191
+ target_schema_slug:
192
+ }
193
+ end
194
+
195
+ def normalize_link_attrs(attrs, cardinality)
196
+ case attrs
197
+ when Array
198
+ attrs
199
+ when Hash
200
+ if attrs.keys.all? { |k| k.to_s =~ /\A\d+\z/ }
201
+ attrs.values
202
+ elsif cardinality == :many
203
+ [attrs]
204
+ else
205
+ [attrs]
206
+ end
207
+ else
208
+ []
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aven
4
+ class Item::Schema::Builder
5
+ attr_reader :fields, :embeds, :links
6
+
7
+ def initialize
8
+ @fields = {}
9
+ @embeds = {}
10
+ @links = {}
11
+ end
12
+
13
+ # Scalar fields
14
+ def string(name, **opts)
15
+ @fields[name] = { type: :string, **opts }
16
+ end
17
+
18
+ def integer(name, **opts)
19
+ @fields[name] = { type: :integer, **opts }
20
+ end
21
+
22
+ def boolean(name, **opts)
23
+ @fields[name] = { type: :boolean, **opts }
24
+ end
25
+
26
+ def date(name, **opts)
27
+ @fields[name] = { type: :string, format: "date", **opts }
28
+ end
29
+
30
+ def datetime(name, **opts)
31
+ @fields[name] = { type: :string, format: "date-time", **opts }
32
+ end
33
+
34
+ def array(name, of:, **opts)
35
+ @fields[name] = { type: :array, items: { type: of }, **opts }
36
+ end
37
+
38
+ # Embeds with inline schema block
39
+ def embeds_many(name, &block)
40
+ embed_builder = EmbedBuilder.new
41
+ embed_builder.instance_eval(&block) if block
42
+ @embeds[name] = { cardinality: :many, fields: embed_builder.fields }
43
+ end
44
+
45
+ def embeds_one(name, &block)
46
+ embed_builder = EmbedBuilder.new
47
+ embed_builder.instance_eval(&block) if block
48
+ @embeds[name] = { cardinality: :one, fields: embed_builder.fields }
49
+ end
50
+
51
+ # Links to other Items
52
+ def links_many(name, class_name: "Aven::Item", inverse_of: nil)
53
+ @links[name] = { cardinality: :many, class_name:, inverse_of: }
54
+ end
55
+
56
+ def links_one(name, class_name: "Aven::Item", inverse_of: nil)
57
+ @links[name] = { cardinality: :one, class_name:, inverse_of: }
58
+ end
59
+
60
+ # JSON Schema generation
61
+ def to_json_schema
62
+ props = {}
63
+ required = []
64
+
65
+ fields.each do |name, config|
66
+ props[name.to_s] = field_to_json_prop(config)
67
+ required << name.to_s if config[:required]
68
+ end
69
+
70
+ embeds.each do |name, config|
71
+ embed_schema = embed_to_json_schema(config[:fields])
72
+ if config[:cardinality] == :many
73
+ props[name.to_s] = { "type" => "array", "items" => embed_schema }
74
+ else
75
+ props[name.to_s] = embed_schema
76
+ end
77
+ end
78
+
79
+ { "type" => "object", "properties" => props, "required" => required }
80
+ end
81
+
82
+ private
83
+
84
+ def field_to_json_prop(config)
85
+ prop = { "type" => config[:type].to_s }
86
+ prop["format"] = config[:format] if config[:format]
87
+ prop["enum"] = config[:enum] if config[:enum]
88
+ prop["maxLength"] = config[:max_length] if config[:max_length]
89
+ prop["minLength"] = config[:min_length] if config[:min_length]
90
+ prop["items"] = { "type" => config[:items][:type].to_s } if config[:items]
91
+ prop
92
+ end
93
+
94
+ def embed_to_json_schema(fields)
95
+ props = {}
96
+ required = []
97
+
98
+ fields.each do |name, config|
99
+ props[name.to_s] = field_to_json_prop(config)
100
+ required << name.to_s if config[:required]
101
+ end
102
+
103
+ { "type" => "object", "properties" => props, "required" => required }
104
+ end
105
+
106
+ # Nested builder for embeds
107
+ class EmbedBuilder
108
+ attr_reader :fields
109
+
110
+ def initialize
111
+ @fields = {}
112
+ end
113
+
114
+ def string(name, **opts)
115
+ @fields[name] = { type: :string, **opts }
116
+ end
117
+
118
+ def integer(name, **opts)
119
+ @fields[name] = { type: :integer, **opts }
120
+ end
121
+
122
+ def boolean(name, **opts)
123
+ @fields[name] = { type: :boolean, **opts }
124
+ end
125
+
126
+ def date(name, **opts)
127
+ @fields[name] = { type: :string, format: "date", **opts }
128
+ end
129
+
130
+ def datetime(name, **opts)
131
+ @fields[name] = { type: :string, format: "date-time", **opts }
132
+ end
133
+
134
+ def array(name, of:, **opts)
135
+ @fields[name] = { type: :array, items: { type: of }, **opts }
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aven
4
+ module Item::Schemaed
5
+ extend ActiveSupport::Concern
6
+
7
+ # Runtime schema access - delegates to resolved_schema (code class or DB record)
8
+ def schema_fields
9
+ schema_source = resolved_schema
10
+ return {} unless schema_source
11
+
12
+ # Code class has .fields, DB record has .fields_config
13
+ schema_source.respond_to?(:fields) ? schema_source.fields : schema_source.fields_config
14
+ end
15
+
16
+ def schema_embeds
17
+ schema_source = resolved_schema
18
+ return {} unless schema_source
19
+
20
+ schema_source.respond_to?(:embeds) ? schema_source.embeds : schema_source.embeds_config
21
+ end
22
+
23
+ def schema_links
24
+ schema_source = resolved_schema
25
+ return {} unless schema_source
26
+
27
+ schema_source.respond_to?(:links) ? schema_source.links : schema_source.links_config
28
+ end
29
+
30
+ def json_schema
31
+ resolved_schema&.to_json_schema
32
+ end
33
+
34
+ # Embed accessors
35
+ def read_embed_many(name)
36
+ cache_key = "@_embed_cache_#{name}"
37
+ return instance_variable_get(cache_key) if instance_variable_defined?(cache_key)
38
+
39
+ raw = data[name.to_s] || []
40
+ embeds = raw.map { |attrs| Item::Embed.new(attrs) }
41
+ instance_variable_set(cache_key, embeds)
42
+ end
43
+
44
+ def write_embed_many(name, value)
45
+ cache_key = "@_embed_cache_#{name}"
46
+ remove_instance_variable(cache_key) if instance_variable_defined?(cache_key)
47
+
48
+ data[name.to_s] = Array(value).map do |v|
49
+ v.is_a?(Item::Embed) ? v.to_h : v
50
+ end
51
+ end
52
+
53
+ def read_embed_one(name)
54
+ cache_key = "@_embed_cache_#{name}"
55
+ return instance_variable_get(cache_key) if instance_variable_defined?(cache_key)
56
+
57
+ raw = data[name.to_s]
58
+ embed = raw ? Item::Embed.new(raw) : nil
59
+ instance_variable_set(cache_key, embed)
60
+ end
61
+
62
+ def write_embed_one(name, value)
63
+ cache_key = "@_embed_cache_#{name}"
64
+ remove_instance_variable(cache_key) if instance_variable_defined?(cache_key)
65
+
66
+ data[name.to_s] = case value
67
+ when Item::Embed then value.to_h
68
+ when Hash then value
69
+ when nil then nil
70
+ end
71
+ end
72
+
73
+ def build_embed(name, attrs = {})
74
+ Item::Embed.new(attrs.merge(id: SecureRandom.uuid))
75
+ end
76
+
77
+ # Link accessors
78
+ def read_link_many(name)
79
+ return Aven::Item.none unless persisted?
80
+
81
+ Aven::Item.active
82
+ .joins("INNER JOIN aven_item_links ON aven_item_links.target_id = aven_items.id")
83
+ .where(aven_item_links: { source_id: id, relation: name.to_s })
84
+ .order("aven_item_links.position")
85
+ end
86
+
87
+ def read_link_many_ids(name)
88
+ return [] unless persisted?
89
+
90
+ Aven::ItemLink.where(source_id: id, relation: name.to_s)
91
+ .ordered
92
+ .pluck(:target_id)
93
+ end
94
+
95
+ def write_link_many_ids(name, ids)
96
+ @_pending_links ||= {}
97
+ @_pending_links[name] = Array(ids).reject(&:blank?)
98
+ end
99
+
100
+ def read_link_one(name)
101
+ return nil unless persisted?
102
+
103
+ Aven::Item.active
104
+ .joins("INNER JOIN aven_item_links ON aven_item_links.target_id = aven_items.id")
105
+ .where(aven_item_links: { source_id: id, relation: name.to_s })
106
+ .first
107
+ end
108
+
109
+ def read_link_one_id(name)
110
+ return nil unless persisted?
111
+
112
+ Aven::ItemLink.find_by(source_id: id, relation: name.to_s)&.target_id
113
+ end
114
+
115
+ def write_link_one_id(name, target_id)
116
+ @_pending_links ||= {}
117
+ @_pending_links[name] = target_id.presence
118
+ end
119
+
120
+ # Dynamic method handling based on schema_slug
121
+ def method_missing(method, *args, &block)
122
+ method_str = method.to_s
123
+
124
+ # Setter
125
+ if method_str.end_with?("=")
126
+ attr_name = method_str.chomp("=").to_sym
127
+ return handle_setter(attr_name, args.first) if schema_has_attribute?(attr_name)
128
+ # Getter
129
+ elsif schema_has_attribute?(method)
130
+ return handle_getter(method)
131
+ end
132
+
133
+ super
134
+ end
135
+
136
+ def respond_to_missing?(method, include_private = false)
137
+ method_str = method.to_s
138
+ attr_name = method_str.end_with?("=") ? method_str.chomp("=").to_sym : method
139
+
140
+ schema_has_attribute?(attr_name) || super
141
+ end
142
+
143
+ private
144
+
145
+ def schema_has_attribute?(name)
146
+ name_str = name.to_s
147
+ name_sym = name.to_sym
148
+
149
+ # Check for *_ids pattern for links
150
+ if name_str.end_with?("_ids")
151
+ base = name_str.chomp("_ids").pluralize.to_sym
152
+ return schema_links.key?(base)
153
+ end
154
+
155
+ # Check for *_id pattern for links_one
156
+ if name_str.end_with?("_id")
157
+ base = name_str.chomp("_id").to_sym
158
+ return schema_links.key?(base) && schema_links[base][:cardinality] == :one
159
+ end
160
+
161
+ # Check for build_* pattern
162
+ if name_str.start_with?("build_")
163
+ base = name_str.delete_prefix("build_")
164
+ singular_base = base.to_sym
165
+ plural_base = base.pluralize.to_sym
166
+ return schema_embeds.key?(singular_base) || schema_embeds.key?(plural_base)
167
+ end
168
+
169
+ # Check for *_attributes= pattern
170
+ if name_str.end_with?("_attributes")
171
+ base = name_str.chomp("_attributes").to_sym
172
+ return schema_embeds.key?(base) || schema_links.key?(base)
173
+ end
174
+
175
+ schema_fields.key?(name_sym) || schema_embeds.key?(name_sym) || schema_links.key?(name_sym)
176
+ end
177
+
178
+ def handle_getter(name)
179
+ name_str = name.to_s
180
+ name_sym = name.to_sym
181
+
182
+ # Link IDs getter
183
+ if name_str.end_with?("_ids")
184
+ base = name_str.chomp("_ids").pluralize.to_sym
185
+ return read_link_many_ids(base)
186
+ end
187
+
188
+ # Link ID getter
189
+ if name_str.end_with?("_id")
190
+ base = name_str.chomp("_id").to_sym
191
+ return read_link_one_id(base)
192
+ end
193
+
194
+ # Build helper
195
+ if name_str.start_with?("build_")
196
+ return build_embed(name_str.delete_prefix("build_"))
197
+ end
198
+
199
+ # Embed getter
200
+ if schema_embeds.key?(name_sym)
201
+ config = schema_embeds[name_sym]
202
+ return config[:cardinality] == :many ? read_embed_many(name_sym) : read_embed_one(name_sym)
203
+ end
204
+
205
+ # Link getter
206
+ if schema_links.key?(name_sym)
207
+ config = schema_links[name_sym]
208
+ return config[:cardinality] == :many ? read_link_many(name_sym) : read_link_one(name_sym)
209
+ end
210
+
211
+ # Field getter
212
+ data[name_str]
213
+ end
214
+
215
+ def handle_setter(name, value)
216
+ name_str = name.to_s
217
+ name_sym = name.to_sym
218
+
219
+ # Link IDs setter
220
+ if name_str.end_with?("_ids")
221
+ base = name_str.chomp("_ids").pluralize.to_sym
222
+ return write_link_many_ids(base, value)
223
+ end
224
+
225
+ # Link ID setter
226
+ if name_str.end_with?("_id")
227
+ base = name_str.chomp("_id").to_sym
228
+ return write_link_one_id(base, value)
229
+ end
230
+
231
+ # Attributes setter (nested attributes)
232
+ if name_str.end_with?("_attributes")
233
+ base = name_str.chomp("_attributes").to_sym
234
+ if schema_embeds.key?(base)
235
+ return process_embed_attributes(base, value)
236
+ elsif schema_links.key?(base)
237
+ return process_link_attributes(base, value)
238
+ end
239
+ return
240
+ end
241
+
242
+ # Embed setter
243
+ if schema_embeds.key?(name_sym)
244
+ config = schema_embeds[name_sym]
245
+ return config[:cardinality] == :many ? write_embed_many(name_sym, value) : write_embed_one(name_sym, value)
246
+ end
247
+
248
+ # Field setter
249
+ data[name_str] = value
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aven
4
+ class Item::Schemas::Base
5
+ class_attribute :_builder
6
+
7
+ class << self
8
+ def inherited(subclass)
9
+ super
10
+ subclass._builder = Item::Schema::Builder.new
11
+ end
12
+
13
+ def slug
14
+ name.demodulize.underscore
15
+ end
16
+
17
+ def builder
18
+ _builder
19
+ end
20
+
21
+ # DSL methods delegate to builder
22
+ def string(name, **opts)
23
+ _builder.string(name, **opts)
24
+ end
25
+
26
+ def integer(name, **opts)
27
+ _builder.integer(name, **opts)
28
+ end
29
+
30
+ def boolean(name, **opts)
31
+ _builder.boolean(name, **opts)
32
+ end
33
+
34
+ def date(name, **opts)
35
+ _builder.date(name, **opts)
36
+ end
37
+
38
+ def datetime(name, **opts)
39
+ _builder.datetime(name, **opts)
40
+ end
41
+
42
+ def array(name, of:, **opts)
43
+ _builder.array(name, of:, **opts)
44
+ end
45
+
46
+ def embeds_many(name, &block)
47
+ _builder.embeds_many(name, &block)
48
+ end
49
+
50
+ def embeds_one(name, &block)
51
+ _builder.embeds_one(name, &block)
52
+ end
53
+
54
+ def links_many(name, class_name: "Aven::Item", inverse_of: nil)
55
+ _builder.links_many(name, class_name:, inverse_of:)
56
+ end
57
+
58
+ def links_one(name, class_name: "Aven::Item", inverse_of: nil)
59
+ _builder.links_one(name, class_name:, inverse_of:)
60
+ end
61
+
62
+ def fields
63
+ _builder&.fields || {}
64
+ end
65
+
66
+ def embeds
67
+ _builder&.embeds || {}
68
+ end
69
+
70
+ def links
71
+ _builder&.links || {}
72
+ end
73
+
74
+ def to_json_schema
75
+ _builder&.to_json_schema
76
+ end
77
+
78
+ # Query helpers - delegate to Item scoped by schema_slug
79
+ def all
80
+ Aven::Item.by_schema(slug)
81
+ end
82
+
83
+ def where(...)
84
+ all.where(...)
85
+ end
86
+
87
+ def find(...)
88
+ all.find(...)
89
+ end
90
+
91
+ def find_by(...)
92
+ all.find_by(...)
93
+ end
94
+
95
+ def create(attrs = {})
96
+ Aven::Item.create(attrs.merge(schema_slug: slug))
97
+ end
98
+
99
+ def create!(attrs = {})
100
+ Aven::Item.create!(attrs.merge(schema_slug: slug))
101
+ end
102
+
103
+ def new(attrs = {})
104
+ Aven::Item.new(attrs.merge(schema_slug: slug))
105
+ end
106
+ end
107
+ end
108
+ end