habarico 7.2.0.pre.beta.5

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 (221) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +82 -0
  4. data/Rakefile +15 -0
  5. data/VERSION +1 -0
  6. data/app/controllers/habarico/action_schema_controller.rb +41 -0
  7. data/app/controllers/habarico/application_controller.rb +15 -0
  8. data/app/controllers/habarico/contrib/preferences/user_preferences_controller.rb +105 -0
  9. data/app/controllers/habarico/global_schema_controller.rb +11 -0
  10. data/app/controllers/habarico/local_schema_controller.rb +35 -0
  11. data/app/helpers/habarico/application_helper.rb +4 -0
  12. data/app/models/habarico/application_record.rb +5 -0
  13. data/app/views/layouts/habarico/application.html.erb +17 -0
  14. data/config/locales/cs.yml +39 -0
  15. data/config/locales/de.yml +39 -0
  16. data/config/locales/en.yml +39 -0
  17. data/config/locales/es.yml +39 -0
  18. data/config/locales/fr.yml +39 -0
  19. data/config/locales/it.yml +39 -0
  20. data/config/locales/ja.yml +39 -0
  21. data/config/locales/nl.yml +39 -0
  22. data/config/locales/pl.yml +39 -0
  23. data/config/locales/pt.yml +39 -0
  24. data/config/locales/ro.yml +39 -0
  25. data/config/locales/sk.yml +39 -0
  26. data/config/locales/uk.yml +39 -0
  27. data/config/routes.rb +1 -0
  28. data/lib/generators/habarico/preferences/install_generator.rb +27 -0
  29. data/lib/generators/habarico/preferences/templates/create_habarico_user_preferences.rb.tt +15 -0
  30. data/lib/habarico/active_model/references.rb +68 -0
  31. data/lib/habarico/authenticatable.rb +31 -0
  32. data/lib/habarico/config.rb +71 -0
  33. data/lib/habarico/context.rb +126 -0
  34. data/lib/habarico/contrib/preferences/base_preferences.rb +40 -0
  35. data/lib/habarico/contrib/preferences/config.rb +31 -0
  36. data/lib/habarico/contrib/preferences/errors.rb +79 -0
  37. data/lib/habarico/contrib/preferences/locales/cs.yml +8 -0
  38. data/lib/habarico/contrib/preferences/locales/de.yml +8 -0
  39. data/lib/habarico/contrib/preferences/locales/en.yml +8 -0
  40. data/lib/habarico/contrib/preferences/locales/es.yml +8 -0
  41. data/lib/habarico/contrib/preferences/locales/fr.yml +8 -0
  42. data/lib/habarico/contrib/preferences/locales/it.yml +8 -0
  43. data/lib/habarico/contrib/preferences/locales/ja.yml +8 -0
  44. data/lib/habarico/contrib/preferences/locales/nl.yml +8 -0
  45. data/lib/habarico/contrib/preferences/locales/pl.yml +8 -0
  46. data/lib/habarico/contrib/preferences/locales/pt.yml +8 -0
  47. data/lib/habarico/contrib/preferences/locales/ro.yml +8 -0
  48. data/lib/habarico/contrib/preferences/locales/sk.yml +8 -0
  49. data/lib/habarico/contrib/preferences/locales/uk.yml +8 -0
  50. data/lib/habarico/contrib/preferences/registry.rb +293 -0
  51. data/lib/habarico/contrib/preferences/routes.rb +17 -0
  52. data/lib/habarico/contrib/preferences/schemas.rb +129 -0
  53. data/lib/habarico/contrib/preferences/serializer.rb +19 -0
  54. data/lib/habarico/contrib/preferences/user_preference.rb +16 -0
  55. data/lib/habarico/contrib/preferences.rb +32 -0
  56. data/lib/habarico/controller.rb +584 -0
  57. data/lib/habarico/definition/action_reference.rb +18 -0
  58. data/lib/habarico/definition/actions/action.rb +14 -0
  59. data/lib/habarico/definition/actions/create_action.rb +18 -0
  60. data/lib/habarico/definition/actions/delete_action.rb +20 -0
  61. data/lib/habarico/definition/actions/edit_action.rb +18 -0
  62. data/lib/habarico/definition/actions/link_action.rb +11 -0
  63. data/lib/habarico/definition/actions/resource_action.rb +12 -0
  64. data/lib/habarico/definition/actions/save_action.rb +14 -0
  65. data/lib/habarico/definition/actions/show_action.rb +18 -0
  66. data/lib/habarico/definition/actions/upload_images_action.rb +11 -0
  67. data/lib/habarico/definition/actions/view_action.rb +11 -0
  68. data/lib/habarico/definition/boxes/array_box.rb +10 -0
  69. data/lib/habarico/definition/boxes/box.rb +32 -0
  70. data/lib/habarico/definition/boxes/gallery_box.rb +10 -0
  71. data/lib/habarico/definition/boxes/grid_box.rb +10 -0
  72. data/lib/habarico/definition/boxes/grid_item_box.rb +10 -0
  73. data/lib/habarico/definition/boxes/list_box.rb +10 -0
  74. data/lib/habarico/definition/boxes/object_box.rb +10 -0
  75. data/lib/habarico/definition/boxes/tab_box.rb +10 -0
  76. data/lib/habarico/definition/boxes/tab_item_box.rb +10 -0
  77. data/lib/habarico/definition/boxes/wizard_box.rb +10 -0
  78. data/lib/habarico/definition/concerns/definition_model.rb +38 -0
  79. data/lib/habarico/definition/definition.rb +206 -0
  80. data/lib/habarico/definition/field_reference.rb +9 -0
  81. data/lib/habarico/definition/filter_reference.rb +7 -0
  82. data/lib/habarico/definition/menu/link_menu_item.rb +12 -0
  83. data/lib/habarico/definition/menu/menu_item.rb +9 -0
  84. data/lib/habarico/definition/menu/text_menu_item.rb +12 -0
  85. data/lib/habarico/definition/menu/view_menu_item.rb +13 -0
  86. data/lib/habarico/definition/response.rb +13 -0
  87. data/lib/habarico/definition/views/action_view.rb +11 -0
  88. data/lib/habarico/definition/views/create_view.rb +15 -0
  89. data/lib/habarico/definition/views/edit_view.rb +15 -0
  90. data/lib/habarico/definition/views/list_view.rb +17 -0
  91. data/lib/habarico/definition/views/show_view.rb +15 -0
  92. data/lib/habarico/definition/views/view.rb +10 -0
  93. data/lib/habarico/engine.rb +6 -0
  94. data/lib/habarico/file_validations.rb +119 -0
  95. data/lib/habarico/generators/abstract_generator.rb +21 -0
  96. data/lib/habarico/generators/action/action_schema_generator.rb +111 -0
  97. data/lib/habarico/generators/action/confirm_dialog_properties_generator.rb +30 -0
  98. data/lib/habarico/generators/action/global_resource_action_schema_generator.rb +46 -0
  99. data/lib/habarico/generators/action/link_action_parameters_generator.rb +31 -0
  100. data/lib/habarico/generators/action/resource_action_endpoint_parameters_generator.rb +70 -0
  101. data/lib/habarico/generators/action/resource_action_parameters_generator.rb +40 -0
  102. data/lib/habarico/generators/action/resource_action_schema_generator.rb +51 -0
  103. data/lib/habarico/generators/action/save_action_parameters_generator.rb +26 -0
  104. data/lib/habarico/generators/action/upload_images_action_parameters_generator.rb +41 -0
  105. data/lib/habarico/generators/action/view_action_endpoint_parameters_generator.rb +37 -0
  106. data/lib/habarico/generators/action/view_action_parameters_generator.rb +56 -0
  107. data/lib/habarico/generators/endpoint/dynamic_render_options_generator.rb +26 -0
  108. data/lib/habarico/generators/endpoint/endpoints_schema_generator.rb +61 -0
  109. data/lib/habarico/generators/endpoint/enum_type_generator.rb +45 -0
  110. data/lib/habarico/generators/endpoint/field_options_generator.rb +179 -0
  111. data/lib/habarico/generators/endpoint/field_schema_generator.rb +381 -0
  112. data/lib/habarico/generators/endpoint/fields_schema_generator.rb +35 -0
  113. data/lib/habarico/generators/endpoint/filter_schema_generator.rb +201 -0
  114. data/lib/habarico/generators/endpoint/filters_schema_generator.rb +25 -0
  115. data/lib/habarico/generators/endpoint/method_schema_generator.rb +34 -0
  116. data/lib/habarico/generators/endpoint/number_field_options_generator.rb +45 -0
  117. data/lib/habarico/generators/endpoint/phone_field_options_generator.rb +29 -0
  118. data/lib/habarico/generators/endpoint/relation_field_options_generator.rb +90 -0
  119. data/lib/habarico/generators/endpoint/search_filter_schema_generator.rb +44 -0
  120. data/lib/habarico/generators/menu/menu_group_schema_generator.rb +63 -0
  121. data/lib/habarico/generators/menu/menu_link_parameters_generator.rb +31 -0
  122. data/lib/habarico/generators/menu/menu_schema_generator.rb +105 -0
  123. data/lib/habarico/generators/menu/menu_view_endpoint_parameters_generator.rb +44 -0
  124. data/lib/habarico/generators/menu/menu_view_parameters_generator.rb +50 -0
  125. data/lib/habarico/generators/resource/action_resource_schema_generator.rb +84 -0
  126. data/lib/habarico/generators/resource/global_resource_schema_generator.rb +70 -0
  127. data/lib/habarico/generators/resource/local_resource_schema_generator.rb +111 -0
  128. data/lib/habarico/generators/resource/object_resource_schema_generator.rb +49 -0
  129. data/lib/habarico/generators/resource/resource_schema_generator.rb +31 -0
  130. data/lib/habarico/generators/view/action_descriptor_generator.rb +88 -0
  131. data/lib/habarico/generators/view/array_box_parameters_generator.rb +34 -0
  132. data/lib/habarico/generators/view/box_options_generator.rb +23 -0
  133. data/lib/habarico/generators/view/box_schema_generator.rb +194 -0
  134. data/lib/habarico/generators/view/detail_view_parameters_generator.rb +57 -0
  135. data/lib/habarico/generators/view/field_descriptor_generator.rb +107 -0
  136. data/lib/habarico/generators/view/filter_descriptor_generator.rb +75 -0
  137. data/lib/habarico/generators/view/gallery_box_parameters_generator.rb +71 -0
  138. data/lib/habarico/generators/view/global_view_schema_generator.rb +59 -0
  139. data/lib/habarico/generators/view/grid_item_box_parameters_generator.rb +44 -0
  140. data/lib/habarico/generators/view/list_box_parameters_generator.rb +124 -0
  141. data/lib/habarico/generators/view/list_view_parameters_generator.rb +120 -0
  142. data/lib/habarico/generators/view/object_box_parameters_generator.rb +26 -0
  143. data/lib/habarico/generators/view/sort_descriptor_generator.rb +30 -0
  144. data/lib/habarico/generators/view/tab_box_parameters_generator.rb +26 -0
  145. data/lib/habarico/generators/view/view_endpoint_parameters_generator.rb +71 -0
  146. data/lib/habarico/generators/view/view_schema_generator.rb +87 -0
  147. data/lib/habarico/generators/view/wizard_box_parameters_generator.rb +32 -0
  148. data/lib/habarico/helpers/action_helper.rb +9 -0
  149. data/lib/habarico/helpers/attributes_helper.rb +67 -0
  150. data/lib/habarico/helpers/box_helper.rb +19 -0
  151. data/lib/habarico/helpers/callable_helper.rb +15 -0
  152. data/lib/habarico/helpers/filter_params_helper.rb +49 -0
  153. data/lib/habarico/helpers/order_by_helper.rb +56 -0
  154. data/lib/habarico/helpers/reflection_helper.rb +61 -0
  155. data/lib/habarico/helpers/sorting_helper.rb +136 -0
  156. data/lib/habarico/schema/action/action_schema.rb +46 -0
  157. data/lib/habarico/schema/action/confirm_dialog_properties.rb +13 -0
  158. data/lib/habarico/schema/action/global_resource_action_schema.rb +29 -0
  159. data/lib/habarico/schema/action/link_action_parameters.rb +15 -0
  160. data/lib/habarico/schema/action/resource_action_endpoint_parameters.rb +24 -0
  161. data/lib/habarico/schema/action/resource_action_parameters.rb +17 -0
  162. data/lib/habarico/schema/action/resource_action_schema.rb +14 -0
  163. data/lib/habarico/schema/action/save_action_parameters.rb +10 -0
  164. data/lib/habarico/schema/action/upload_images_action_parameters.rb +23 -0
  165. data/lib/habarico/schema/action/view_action_endpoint_parameters.rb +16 -0
  166. data/lib/habarico/schema/action/view_action_parameters.rb +27 -0
  167. data/lib/habarico/schema/endpoint/ai_field_options.rb +10 -0
  168. data/lib/habarico/schema/endpoint/box_options.rb +10 -0
  169. data/lib/habarico/schema/endpoint/choices_field_options.rb +10 -0
  170. data/lib/habarico/schema/endpoint/dynamic_render_options.rb +14 -0
  171. data/lib/habarico/schema/endpoint/endpoints_schema.rb +22 -0
  172. data/lib/habarico/schema/endpoint/enum_type.rb +21 -0
  173. data/lib/habarico/schema/endpoint/field_options.rb +56 -0
  174. data/lib/habarico/schema/endpoint/field_schema.rb +65 -0
  175. data/lib/habarico/schema/endpoint/filter_schema.rb +44 -0
  176. data/lib/habarico/schema/endpoint/image_field_options.rb +13 -0
  177. data/lib/habarico/schema/endpoint/key_value.rb +13 -0
  178. data/lib/habarico/schema/endpoint/method_schema.rb +13 -0
  179. data/lib/habarico/schema/endpoint/number_field_options.rb +24 -0
  180. data/lib/habarico/schema/endpoint/phone_field_options.rb +13 -0
  181. data/lib/habarico/schema/endpoint/relation_field_options.rb +39 -0
  182. data/lib/habarico/schema/extensible_enum.rb +20 -0
  183. data/lib/habarico/schema/menu/action_view_choice.rb +13 -0
  184. data/lib/habarico/schema/menu/menu_group_schema.rb +26 -0
  185. data/lib/habarico/schema/menu/menu_schema.rb +36 -0
  186. data/lib/habarico/schema/resource/global_resource_schema.rb +14 -0
  187. data/lib/habarico/schema/resource/local_resource_schema.rb +14 -0
  188. data/lib/habarico/schema/resource/object_resource_schema.rb +8 -0
  189. data/lib/habarico/schema/resource/resource_schema.rb +16 -0
  190. data/lib/habarico/schema/types.rb +102 -0
  191. data/lib/habarico/schema/view/action_descriptor.rb +30 -0
  192. data/lib/habarico/schema/view/array_box_parameters.rb +13 -0
  193. data/lib/habarico/schema/view/box_schema.rb +67 -0
  194. data/lib/habarico/schema/view/detail_view_parameters.rb +16 -0
  195. data/lib/habarico/schema/view/field_descriptor.rb +50 -0
  196. data/lib/habarico/schema/view/field_wrapper_properties.rb +13 -0
  197. data/lib/habarico/schema/view/filter_descriptor.rb +24 -0
  198. data/lib/habarico/schema/view/gallery_box_parameters.rb +31 -0
  199. data/lib/habarico/schema/view/global_view_schema.rb +25 -0
  200. data/lib/habarico/schema/view/grid_item_box_parameters.rb +13 -0
  201. data/lib/habarico/schema/view/list_box_parameters.rb +44 -0
  202. data/lib/habarico/schema/view/list_view_parameters.rb +31 -0
  203. data/lib/habarico/schema/view/object_box_parameters.rb +10 -0
  204. data/lib/habarico/schema/view/sort_descriptor.rb +15 -0
  205. data/lib/habarico/schema/view/sortable_parameters.rb +13 -0
  206. data/lib/habarico/schema/view/tab_box_parameters.rb +10 -0
  207. data/lib/habarico/schema/view/view_action_descriptor_parameters.rb +21 -0
  208. data/lib/habarico/schema/view/view_endpoint_parameters.rb +22 -0
  209. data/lib/habarico/schema/view/view_schema.rb +33 -0
  210. data/lib/habarico/schema/view/wizard_box_parameters.rb +13 -0
  211. data/lib/habarico/search/field_resolver.rb +138 -0
  212. data/lib/habarico/search/search_matcher.rb +152 -0
  213. data/lib/habarico/serializers/meta_serializer.rb +42 -0
  214. data/lib/habarico/serializers/record_serializer.rb +33 -0
  215. data/lib/habarico/serializers/serializer.rb +61 -0
  216. data/lib/habarico/services/resource_service.rb +69 -0
  217. data/lib/habarico/services/routing_service.rb +160 -0
  218. data/lib/habarico/services/schema_service.rb +72 -0
  219. data/lib/habarico/version.rb +5 -0
  220. data/lib/habarico.rb +262 -0
  221. metadata +324 -0
@@ -0,0 +1,13 @@
1
+ module Habarico
2
+ class WizardBoxParameters
3
+ include ::ActiveModel::Model
4
+ include Eluvia::ActiveModel::Attributes
5
+
6
+ # Show previous/next navigation buttons in the box toolbar
7
+ boolean_attr :show_toolbar_navigation
8
+
9
+ # Validate the current step before moving to the next one
10
+ boolean_attr :validate_steps
11
+
12
+ end
13
+ end
@@ -0,0 +1,138 @@
1
+ module Habarico
2
+ module Search
3
+
4
+ # A resolved leaf field of a `search_fields` path.
5
+ #
6
+ # `model` is the class owning the leaf, `name` the leaf attribute name, `path` the original
7
+ # (possibly relation-traversing) path, `type` the search classification and `column` the
8
+ # ActiveRecord column backing the leaf (nil for pure attributes-model attributes).
9
+ LeafField = Struct.new(:model, :name, :path, :type, :column, keyword_init: true) do
10
+
11
+ def label
12
+ return name.to_s unless model.respond_to?(:human_attribute_name)
13
+
14
+ model.human_attribute_name(name)
15
+ end
16
+
17
+ def choices?
18
+ type == :choices
19
+ end
20
+
21
+ def boolean?
22
+ type == :boolean
23
+ end
24
+
25
+ def numeric?
26
+ type == :numeric
27
+ end
28
+
29
+ def text?
30
+ type == :text
31
+ end
32
+
33
+ end
34
+
35
+ # Resolves a `search_fields` path (e.g. `author__name`) against a model and classifies the
36
+ # leaf field's type. Ruby port of the Python `walk_to_leaf_field` helper, extended with the
37
+ # type detection used by the search matcher and highlighter - it is the single source of truth
38
+ # both build on.
39
+ #
40
+ # Intermediate hops must be relations (ActiveRecord associations or Habarico references); the
41
+ # leaf must be an ActiveRecord column or an enum attribute. Unresolvable paths yield `nil`
42
+ # (mirrors the Python behaviour, so a stale entry never crashes the request); use `resolve!`
43
+ # to raise instead - handy for catching typos in tests.
44
+ module FieldResolver
45
+
46
+ RELATION_SEPARATOR = '__'.freeze
47
+ NUMERIC_COLUMN_TYPES = %i[integer float decimal].freeze
48
+
49
+ # Returns a LeafField for `path` on `model`, or nil when it cannot be resolved.
50
+ def self.resolve(model, path)
51
+ return nil if model.nil? || path.nil?
52
+
53
+ # Negative limit keeps trailing empty segments so `title__` is rejected too.
54
+ parts = path.to_s.split(RELATION_SEPARATOR, -1)
55
+ return nil if parts.empty? || parts.any?(&:empty?)
56
+
57
+ current = model
58
+ parts[0..-2].each do |part|
59
+ current = step_into_relation(current, part)
60
+ return nil if current.nil?
61
+ end
62
+
63
+ build_leaf(current, parts.last, path.to_s)
64
+ end
65
+
66
+ # Like `resolve`, but raises ArgumentError when the path cannot be resolved.
67
+ def self.resolve!(model, path)
68
+ resolve(model, path) || raise(ArgumentError, "Cannot resolve search field path #{path.inspect} on #{model.inspect}")
69
+ end
70
+
71
+ # Follows one relation hop, returning the associated model class or nil when `part` is not a
72
+ # traversable relation. Prefers ActiveRecord associations, falls back to Habarico references.
73
+ def self.step_into_relation(model, part)
74
+ if model.respond_to?(:reflect_on_association)
75
+ reflection = model.reflect_on_association(part.to_sym)
76
+ return reflection_klass(reflection) if reflection
77
+ end
78
+
79
+ if model.respond_to?(:has_reference?) && model.has_reference?(part) && model.respond_to?(:find_reference)
80
+ return reference_klass(model.find_reference(part))
81
+ end
82
+
83
+ nil
84
+ end
85
+
86
+ # The target class of an ActiveRecord reflection, or nil for polymorphic associations that
87
+ # have no single class.
88
+ def self.reflection_klass(reflection)
89
+ reflection.klass
90
+ rescue StandardError
91
+ nil
92
+ end
93
+
94
+ # The target class of a Habarico reference descriptor (`class_name` may be a Class or String).
95
+ def self.reference_klass(reference)
96
+ return nil if reference.nil?
97
+
98
+ class_name = reference[:class_name]
99
+ class_name.is_a?(::String) ? class_name.constantize : class_name
100
+ rescue NameError
101
+ nil
102
+ end
103
+
104
+ def self.build_leaf(model, name, path)
105
+ type = detect_type(model, name)
106
+ return nil if type.nil?
107
+
108
+ LeafField.new(model: model, name: name, path: path, type: type, column: column_for(model, name))
109
+ end
110
+
111
+ # Classifies the leaf field. Enum attributes are choices (regardless of their underlying
112
+ # column type); otherwise the ActiveRecord column type decides. An unknown leaf yields nil.
113
+ def self.detect_type(model, name)
114
+ return :choices if enum?(model, name)
115
+
116
+ column = column_for(model, name)
117
+ return nil if column.nil?
118
+
119
+ case column.type
120
+ when :boolean then :boolean
121
+ when *NUMERIC_COLUMN_TYPES then :numeric
122
+ else :text
123
+ end
124
+ end
125
+
126
+ def self.enum?(model, name)
127
+ model.respond_to?(:has_enum_attr?) && model.has_enum_attr?(name)
128
+ end
129
+
130
+ def self.column_for(model, name)
131
+ return nil unless model.respond_to?(:columns_hash)
132
+
133
+ model.columns_hash[name.to_s]
134
+ end
135
+
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,152 @@
1
+ module Habarico
2
+ module Search
3
+
4
+ # Applies a typed fulltext search over a resource's `search_fields`. Ruby port of the Python
5
+ # `SearchFilter` (MR !198): Habarico owns the whole search query rather than delegating to
6
+ # the base library's `filter_2!({search:})`, whose `apply_search` matches a separate FiltersModel field
7
+ # list, cannot traverse relations and always unaccents. Owning it here makes
8
+ # `Definition#search_fields` the single source of truth shared with SearchHighlighter and lets
9
+ # `Config.search_unaccent_enabled` govern matching and highlighting consistently.
10
+ #
11
+ # Semantics mirror the Python matcher: AND across query words, OR across fields. Per field the
12
+ # leaf type (from FieldResolver) decides the match:
13
+ #
14
+ # - text -> `lower([unaccent](col)) LIKE %word%`
15
+ # - choices -> the word is matched against the human-readable enum labels; the resulting raw
16
+ # values feed an `IN (...)` lookup (no match -> the field contributes nothing)
17
+ # - boolean -> matched against the translated Yes/No labels -> `IN (true/false)`
18
+ # - numeric -> `lower(CAST(col AS text)) LIKE %word%`
19
+ #
20
+ # Relations (`author__name`) are reached with `left_outer_joins`; matches are collected as
21
+ # primary keys and folded back with `where(pk IN ...)` so joins never duplicate rows or skew
22
+ # kaminari's `total_count`.
23
+ class SearchMatcher
24
+
25
+ # Unicode combining marks, dropped when matching diacritics-insensitively. Lives here, in the
26
+ # free core, because the free matcher needs it; a pro/enterprise highlighter reuses it from
27
+ # here rather than the other way round (HAB-812).
28
+ COMBINING_MARK = /\p{Mn}/
29
+
30
+ def initialize(model:, search_fields:, unaccent: nil)
31
+ @model = model
32
+ @search_fields = search_fields.to_a
33
+ @unaccent = unaccent.nil? ? Habarico::Config.search_unaccent_enabled : unaccent
34
+ end
35
+
36
+ # Narrows `scope` to records matching every query word in at least one `search_fields` path.
37
+ # Returns `scope` unchanged when the query is blank or no search fields are declared.
38
+ def apply(scope, query)
39
+ return scope if query.nil?
40
+
41
+ words = query.to_s.split(/\s+/).reject(&:empty?)
42
+ return scope if words.empty? || @search_fields.empty?
43
+
44
+ matched = nil
45
+ words.each do |word|
46
+ word_ids = ids_for_word(scope, word)
47
+ matched = matched.nil? ? word_ids : (matched & word_ids)
48
+ break if matched.empty?
49
+ end
50
+
51
+ scope.where(primary_key => matched || [])
52
+ end
53
+
54
+ private
55
+
56
+ def primary_key
57
+ @model.primary_key
58
+ end
59
+
60
+ # Primary keys of records where any field matches `word` (OR across fields).
61
+ def ids_for_word(scope, word)
62
+ @search_fields.each_with_object([]) do |path, acc|
63
+ leaf = FieldResolver.resolve(@model, path)
64
+ next if leaf.nil?
65
+
66
+ relation = field_relation(scope, path, leaf, word)
67
+ acc.concat(relation.pluck(primary_key)) unless relation.nil?
68
+ end.uniq
69
+ end
70
+
71
+ # A relation selecting records where `leaf` matches `word`, or nil when the field cannot
72
+ # match this word at all (e.g. a choices/boolean word matching no label).
73
+ def field_relation(scope, path, leaf, word)
74
+ joined = join_for(scope, path)
75
+ column = qualified_column(leaf)
76
+
77
+ if leaf.choices?
78
+ values = matching_choice_values(leaf, word)
79
+ values.empty? ? nil : joined.where("#{column} IN (?)", values)
80
+ elsif leaf.boolean?
81
+ values = matching_boolean_values(word)
82
+ values.empty? ? nil : joined.where("#{column} IN (?)", values)
83
+ elsif leaf.numeric?
84
+ joined.where("lower(CAST(#{column} AS text)) LIKE ?", like_pattern(word, unaccent: false))
85
+ else
86
+ joined.where("lower(#{text_expr(column)}) LIKE ?", like_pattern(word, unaccent: @unaccent))
87
+ end
88
+ end
89
+
90
+ # Left-joins the relation hops of `path` so a leaf on a related table can be referenced.
91
+ def join_for(scope, path)
92
+ hops = path.to_s.split(FieldResolver::RELATION_SEPARATOR)[0..-2].map(&:to_sym)
93
+ return scope if hops.empty?
94
+
95
+ association = hops.reverse.reduce { |nested, hop| { hop => nested } }
96
+ scope.left_outer_joins(association)
97
+ end
98
+
99
+ # `table.column` for the leaf, using the leaf's owning table (the joined table for relations).
100
+ def qualified_column(leaf)
101
+ "#{leaf.model.table_name}.#{leaf.name}"
102
+ end
103
+
104
+ # Wraps the column in `unaccent(...)` when diacritics-insensitive matching is on.
105
+ def text_expr(column)
106
+ @unaccent ? "unaccent(#{column})" : column
107
+ end
108
+
109
+ # A `%word%` LIKE pattern, lowercased and (optionally) accent-stripped to match `text_expr`.
110
+ def like_pattern(word, unaccent:)
111
+ normalized = normalize(word, unaccent: unaccent)
112
+ "%#{normalized}%"
113
+ end
114
+
115
+ # Raw enum values whose human-readable label contains `word`.
116
+ def matching_choice_values(leaf, word)
117
+ choices = leaf.model.find_enum_attr(leaf.name)
118
+ return [] if choices.nil?
119
+
120
+ needle = normalize(word, unaccent: @unaccent)
121
+ choices.values
122
+ .select { |choice| normalize(choice[:label], unaccent: @unaccent).include?(needle) }
123
+ .map { |choice| choice[:value] }
124
+ end
125
+
126
+ # Boolean raw values whose translated Yes/No label contains `word`.
127
+ def matching_boolean_values(word)
128
+ needle = normalize(word, unaccent: @unaccent)
129
+ values = []
130
+ values << true if normalize(yes_label, unaccent: @unaccent).include?(needle)
131
+ values << false if normalize(no_label, unaccent: @unaccent).include?(needle)
132
+ values
133
+ end
134
+
135
+ def normalize(value, unaccent:)
136
+ value = value.to_s.downcase
137
+ return value unless unaccent
138
+
139
+ value.unicode_normalize(:nfkd).each_char.grep_v(COMBINING_MARK).join
140
+ end
141
+
142
+ def yes_label
143
+ defined?(I18n) ? I18n.t('search.yes', default: 'Yes') : 'Yes'
144
+ end
145
+
146
+ def no_label
147
+ defined?(I18n) ? I18n.t('search.no', default: 'No') : 'No'
148
+ end
149
+
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,42 @@
1
+ module Habarico
2
+ module Serializers
3
+ class MetaSerializer
4
+ include Eluvia::Serializers::FieldsetSerializer
5
+
6
+ class SchemaSerializer
7
+ include Eluvia::Serializers::FieldsetSerializer
8
+
9
+ READABLE_FIELDS = %w[name label pk_field_name actions].freeze
10
+
11
+ def readable_fields
12
+ READABLE_FIELDS
13
+ end
14
+
15
+ def read_attribute(record, field_name)
16
+ record[field_name]
17
+ end
18
+ end
19
+
20
+ READABLE_FIELDS = %w[repr schema tags trigger_action].freeze
21
+
22
+ def initialize(schema_serializer: SchemaSerializer.new)
23
+ @schema_serializer = schema_serializer
24
+ end
25
+
26
+ def readable_fields
27
+ READABLE_FIELDS
28
+ end
29
+
30
+ def serialize(data, fields: nil)
31
+ read_fieldset(fields).to_h { |rest_field| [rest_field.name, read_attribute(data, rest_field.name, rest_field.fieldset)] }
32
+ end
33
+
34
+ def read_attribute(data, field_name, sub_fieldset = nil)
35
+ return data[field_name] unless field_name == 'schema'
36
+
37
+ @schema_serializer.serialize(data['schema'], fields: sub_fieldset&.to_s)
38
+ end
39
+
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,33 @@
1
+ module Habarico
2
+ module Serializers
3
+ class RecordSerializer
4
+ include Eluvia::Serializers::FieldsetSerializer
5
+
6
+ META_FIELD = 'meta'.freeze
7
+
8
+ def initialize(serializer:, meta_serializer:)
9
+ @serializer = serializer
10
+ @meta_serializer = meta_serializer
11
+ end
12
+
13
+ def readable_fields
14
+ @serializer.readable_fields + [META_FIELD]
15
+ end
16
+
17
+ def serialize(record, meta, fields: nil)
18
+ read_fieldset(fields).each_with_object({}) do |rest_field, result|
19
+ next if rest_field.name != META_FIELD && !record.respond_to?(rest_field.name)
20
+
21
+ result[rest_field.name] = read_attribute(record, rest_field.name, rest_field.fieldset, meta)
22
+ end
23
+ end
24
+
25
+ def read_attribute(record, field_name, sub_fieldset, meta)
26
+ return @meta_serializer.serialize(meta, fields: sub_fieldset&.to_s) if field_name == META_FIELD
27
+
28
+ @serializer.read_attribute(record, field_name)
29
+ end
30
+
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,61 @@
1
+ module Habarico
2
+ module Serializers
3
+ class Serializer
4
+ include Eluvia::Serializers::FieldsetSerializer
5
+
6
+ def initialize(definition)
7
+ @definition = definition
8
+ end
9
+
10
+ def serialize(record, fields: nil)
11
+ read_fieldset(fields).each_with_object({}) do |rest_field, result|
12
+ next unless record.respond_to?(rest_field.name)
13
+
14
+ result[rest_field.name] = read_attribute(record, rest_field.name)
15
+ end
16
+ end
17
+
18
+ def readable_fields
19
+ field_names
20
+ end
21
+
22
+ def read_attribute(record, field_name)
23
+ model = @definition.model
24
+
25
+ # Handle "belongs to" and "has one" associations
26
+ if Habarico::ReflectionHelper.is_active_record_class?(model)
27
+ reflection = model.reflect_on_association(field_name)
28
+ return record.send(reflection.foreign_key) if reflection && %i[belongs_to has_one].include?(reflection.macro)
29
+ end
30
+
31
+ # Handle enum attribute
32
+ if model.respond_to?(:has_enum_attr?) && model.has_enum_attr?(field_name)
33
+ enum_obj = record.send("#{field_name}_obj")
34
+ return enum_obj ? enum_obj[:value] : nil
35
+ end
36
+
37
+ # Fallback to a standard model attribute
38
+ record.send(field_name)
39
+ end
40
+
41
+ private
42
+
43
+ def field_names
44
+ (@definition.fields + @definition.read_only_fields).uniq.map { |field| field_name_for(field) }
45
+ end
46
+
47
+ def field_name_for(field)
48
+ if field.is_a?(Habarico::FieldReference)
49
+ field.name.to_s
50
+ elsif field.is_a?(::String) || field.is_a?(::Symbol)
51
+ field.to_s
52
+ elsif field.is_a?(::Hash)
53
+ field.keys.first.to_s
54
+ else
55
+ raise Eluvia::Errors::StandardError.new("Unsupported field type: #{field.class.name}")
56
+ end
57
+ end
58
+
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,69 @@
1
+ module Habarico
2
+ class ResourceService
3
+ include Singleton
4
+
5
+ def get_controller_class(resource_path)
6
+ return nil if resource_path.blank?
7
+ controller_class_name = ''
8
+ controller_class_name += "#{Habarico::Config.routing_scope.camelize}::" if Habarico::Config.routing_scope.present?
9
+ controller_class_name += resource_path.map { |part| part.to_s.camelize }.join('::') + 'Controller' # rubocop:disable Style/StringConcatenation -- would be less readable
10
+ controller_class_name.constantize
11
+ rescue NameError
12
+ raise Eluvia::Errors::NotFound.new("Controller class #{controller_class_name} not found for resource #{resource_path.join('.')}")
13
+ end
14
+
15
+ def get_definition(resource_path)
16
+ return nil if resource_path.blank?
17
+ get_controller_class(resource_path)&.definition
18
+ end
19
+
20
+ def get_resource_path(resource_name)
21
+ return nil if resource_name.blank?
22
+ flatten_specs(Habarico::Config.resources).find { |resource_path| resource_path.last == resource_name.to_sym }
23
+ end
24
+
25
+ def get_resources_paths
26
+ flatten_specs(Habarico::Config.resources)
27
+ end
28
+
29
+ def find_resource_name_by_model_class_or_instance(model_class_or_instance)
30
+ return nil if model_class_or_instance.blank?
31
+ if Habarico::ReflectionHelper.is_active_record_class?(model_class_or_instance)
32
+ return find_resource_name_by_model_class(model_class_or_instance)
33
+ end
34
+ if Habarico::ReflectionHelper.is_active_record_instance?(model_class_or_instance)
35
+ return find_resource_name_by_model_class(model_class_or_instance.class)
36
+ end
37
+ nil
38
+ end
39
+
40
+ def find_resource_name_by_model_class(model_class)
41
+ return nil if model_class.blank?
42
+ get_resources_paths.each do |resource_path|
43
+ controller_class = get_controller_class(resource_path)
44
+ if controller_class && controller_class.definition.model == model_class # match model class
45
+ return resource_path.last # return resource name
46
+ end
47
+ end
48
+ nil
49
+ end
50
+
51
+ private
52
+
53
+ def flatten_specs(specs, namespaces = [])
54
+ result = []
55
+ specs.each do |resource_or_namespace|
56
+ if resource_or_namespace.is_a?(Hash)
57
+ resource_or_namespace.each do |namespace_name, specs_2|
58
+ result += flatten_specs(specs_2, namespaces + [namespace_name])
59
+ end
60
+ else
61
+ resource_name = resource_or_namespace
62
+ result << (namespaces + [resource_name])
63
+ end
64
+ end
65
+ result
66
+ end
67
+
68
+ end
69
+ end
@@ -0,0 +1,160 @@
1
+ module Habarico
2
+ class RoutingService
3
+ include Singleton
4
+
5
+ # Opt-in contrib modules register extra route blocks. They are drawn at the admin-site
6
+ # root - beside schema/search - which for a grouped resource config (e.g.
7
+ # `{ admin: [...] }`) means under the top-level group's path, and for a flat resource
8
+ # list means directly under `routing_url_prefix`. This mirrors the Python AdminSite,
9
+ # where user-preferences sits next to schema/search. See draw_extension_routes.
10
+ @extension_route_blocks = []
11
+
12
+ # Route blocks drawn *inside* every resource's own `resources ... do` block, where a feature
13
+ # can add member/collection routes to each resource (the aggregate endpoint does). Registered
14
+ # by a pro/enterprise gem; empty in a free-only install - see draw_resource_extension_routes.
15
+ @resource_extension_route_blocks = []
16
+
17
+ class << self
18
+ attr_reader :extension_route_blocks, :resource_extension_route_blocks
19
+
20
+ def register_route_block(&block)
21
+ extension_route_blocks << block
22
+ end
23
+
24
+ # Registers a block drawn inside each resource's route block. The block is instance_exec'd
25
+ # in the Rails mapper, so it may use `collection` / `member` exactly as core's own route
26
+ # helpers do, and receives the resource name.
27
+ def register_resource_route_block(&block)
28
+ resource_extension_route_blocks << block
29
+ end
30
+
31
+ # Draws the registered per-resource blocks. Called once per resource, before core's own
32
+ # routes: a collection route added here must not be shadowed by the member `GET /:id/schema`
33
+ # route, which has no `:id` constraint and would otherwise match it first.
34
+ def draw_resource_extension_routes(mapper, resource_name)
35
+ resource_extension_route_blocks.each do |block|
36
+ mapper.instance_exec(resource_name, &block)
37
+ end
38
+ end
39
+
40
+ # Draws the registered extension blocks in the given routing mapper, at the admin-site
41
+ # root so they sit beside schema/search - matching the Python AdminSite. When the
42
+ # resources are wrapped in a top-level group the site root is that group's path, so
43
+ # re-open it here; for a flat resource list it is the url prefix itself. This is a
44
+ # path-only `scope` (not a `namespace`) on purpose: the contrib controllers live in the
45
+ # gem, not under the host's module, so no module prefix may be applied to them.
46
+ def draw_extension_routes(mapper)
47
+ top_group = Habarico::Config.resources.find { |spec| spec.is_a?(Hash) }&.keys&.first
48
+ return draw_extension_route_blocks(mapper) unless top_group
49
+
50
+ mapper.scope(top_group) { draw_extension_route_blocks(mapper) }
51
+ end
52
+
53
+ private
54
+
55
+ # The blocks are written against the routing mapper DSL, so they must be evaluated in
56
+ # the mapper's context (`scope` above only changes the mapper state, not `self`).
57
+ def draw_extension_route_blocks(mapper)
58
+ extension_route_blocks.each { |route_block| mapper.instance_exec(&route_block) }
59
+ end
60
+ end
61
+
62
+ def draw_routes
63
+ Rails.application.routes.draw do
64
+ def _draw_routes(specs)
65
+ specs.each do |resource_or_namespace|
66
+ if resource_or_namespace.is_a?(Hash)
67
+ resource_or_namespace.each do |namespace_name, specs_2|
68
+ namespace namespace_name do
69
+ _draw_routes(specs_2)
70
+ end
71
+ end
72
+ else
73
+ get 'schema', to: '/habarico/global_schema#index'
74
+ _draw_resource_routes(resource_or_namespace)
75
+ end
76
+ end
77
+ end
78
+
79
+ def _draw_resource_routes(resource_name)
80
+ resources resource_name, only: %i[index show create update destroy] do
81
+ # Must be drawn before `_draw_schema_routes`: its member route `GET /:id/schema` has no `:id`
82
+ # constraint, so it would otherwise greedily match a contributed collection route too (with
83
+ # `:id` == "aggregate", say) and shadow it, since Rails resolves ambiguous matches by
84
+ # registration order.
85
+ Habarico::RoutingService.draw_resource_extension_routes(self, resource_name)
86
+ _draw_actions_routes(resource_name)
87
+ _draw_schema_routes
88
+ end
89
+ end
90
+
91
+ def _draw_actions_routes(resource_name)
92
+ resource_path = Habarico::ResourceService.instance.get_resource_path(resource_name)
93
+ controller_class = Habarico::ResourceService.instance.get_controller_class(resource_path)
94
+ registered_actions = controller_class.registered_actions
95
+
96
+ # Member actions
97
+ member_actions = registered_actions.select { |action| action[:is_detail] }
98
+ unless member_actions.empty?
99
+ member do
100
+ member_actions.each do |action|
101
+ _draw_action_routes(action, is_member: true)
102
+ end
103
+ end
104
+ end
105
+
106
+ # Collection actions
107
+ collection_actions = registered_actions.reject { |action| action[:is_detail] }
108
+ unless collection_actions.empty?
109
+ collection do
110
+ collection_actions.each do |action|
111
+ _draw_action_routes(action, is_member: false)
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ def _draw_action_routes(action, is_member: true)
118
+ if action[:method] == :get
119
+ get action[:name]
120
+ elsif action[:method] == :post
121
+ post action[:name]
122
+ elsif action[:method] == :put
123
+ put action[:name]
124
+ elsif action[:method] == :patch
125
+ patch action[:name]
126
+ elsif action[:method] == :delete
127
+ delete action[:name]
128
+ else
129
+ raise Eluvia::Errors::StandardError.new("Unsupported HTTP method #{action[:method]} for action #{action[:name]}")
130
+ end
131
+ get "#{action[:name]}/schema", to: "/habarico/action_schema##{is_member ? 'show' : 'index'}"
132
+ end
133
+
134
+ def _draw_schema_routes
135
+ collection do
136
+ get :schema, to: '/habarico/local_schema#index'
137
+ end
138
+ member do
139
+ get :schema, to: '/habarico/local_schema#show'
140
+ end
141
+ end
142
+
143
+ scope Habarico::Config.routing_url_prefix do
144
+ if Habarico::Config.routing_scope
145
+ scope module: Habarico::Config.routing_scope do
146
+ _draw_routes(Habarico::Config.resources)
147
+ end
148
+ else
149
+ _draw_routes(Habarico::Config.resources)
150
+ end
151
+
152
+ # Opt-in contrib extension routes (e.g. user-preferences) - see
153
+ # RoutingService.draw_extension_routes for the placement rules.
154
+ Habarico::RoutingService.draw_extension_routes(self)
155
+ end
156
+ end
157
+ end
158
+
159
+ end
160
+ end