search-engine-for-typesense 1.0.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 (139) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +148 -0
  4. data/app/search_engine/search_engine/app_info.rb +11 -0
  5. data/app/search_engine/search_engine/index_partition_job.rb +170 -0
  6. data/lib/generators/search_engine/install/install_generator.rb +20 -0
  7. data/lib/generators/search_engine/install/templates/initializer.rb.tt +230 -0
  8. data/lib/generators/search_engine/model/model_generator.rb +86 -0
  9. data/lib/generators/search_engine/model/templates/model.rb.tt +12 -0
  10. data/lib/search-engine-for-typesense.rb +12 -0
  11. data/lib/search_engine/active_record_syncable.rb +247 -0
  12. data/lib/search_engine/admin/stopwords.rb +125 -0
  13. data/lib/search_engine/admin/synonyms.rb +125 -0
  14. data/lib/search_engine/admin.rb +12 -0
  15. data/lib/search_engine/ast/and.rb +52 -0
  16. data/lib/search_engine/ast/binary_op.rb +75 -0
  17. data/lib/search_engine/ast/eq.rb +19 -0
  18. data/lib/search_engine/ast/group.rb +18 -0
  19. data/lib/search_engine/ast/gt.rb +12 -0
  20. data/lib/search_engine/ast/gte.rb +12 -0
  21. data/lib/search_engine/ast/in.rb +28 -0
  22. data/lib/search_engine/ast/lt.rb +12 -0
  23. data/lib/search_engine/ast/lte.rb +12 -0
  24. data/lib/search_engine/ast/matches.rb +55 -0
  25. data/lib/search_engine/ast/node.rb +176 -0
  26. data/lib/search_engine/ast/not_eq.rb +13 -0
  27. data/lib/search_engine/ast/not_in.rb +24 -0
  28. data/lib/search_engine/ast/or.rb +52 -0
  29. data/lib/search_engine/ast/prefix.rb +51 -0
  30. data/lib/search_engine/ast/raw.rb +41 -0
  31. data/lib/search_engine/ast/unary_op.rb +43 -0
  32. data/lib/search_engine/ast.rb +101 -0
  33. data/lib/search_engine/base/creation.rb +727 -0
  34. data/lib/search_engine/base/deletion.rb +80 -0
  35. data/lib/search_engine/base/display_coercions.rb +36 -0
  36. data/lib/search_engine/base/hydration.rb +312 -0
  37. data/lib/search_engine/base/index_maintenance/cleanup.rb +202 -0
  38. data/lib/search_engine/base/index_maintenance/lifecycle.rb +251 -0
  39. data/lib/search_engine/base/index_maintenance/schema.rb +117 -0
  40. data/lib/search_engine/base/index_maintenance.rb +459 -0
  41. data/lib/search_engine/base/indexing_dsl.rb +255 -0
  42. data/lib/search_engine/base/joins.rb +479 -0
  43. data/lib/search_engine/base/model_dsl.rb +472 -0
  44. data/lib/search_engine/base/presets.rb +43 -0
  45. data/lib/search_engine/base/pretty_printer.rb +315 -0
  46. data/lib/search_engine/base/relation_delegation.rb +42 -0
  47. data/lib/search_engine/base/scopes.rb +113 -0
  48. data/lib/search_engine/base/updating.rb +92 -0
  49. data/lib/search_engine/base.rb +38 -0
  50. data/lib/search_engine/bulk.rb +284 -0
  51. data/lib/search_engine/cache.rb +33 -0
  52. data/lib/search_engine/cascade.rb +531 -0
  53. data/lib/search_engine/cli/doctor.rb +631 -0
  54. data/lib/search_engine/cli/support.rb +217 -0
  55. data/lib/search_engine/cli.rb +222 -0
  56. data/lib/search_engine/client/http_adapter.rb +63 -0
  57. data/lib/search_engine/client/request_builder.rb +92 -0
  58. data/lib/search_engine/client/services/base.rb +74 -0
  59. data/lib/search_engine/client/services/collections.rb +161 -0
  60. data/lib/search_engine/client/services/documents.rb +214 -0
  61. data/lib/search_engine/client/services/operations.rb +152 -0
  62. data/lib/search_engine/client/services/search.rb +190 -0
  63. data/lib/search_engine/client/services.rb +29 -0
  64. data/lib/search_engine/client.rb +765 -0
  65. data/lib/search_engine/client_options.rb +20 -0
  66. data/lib/search_engine/collection_resolver.rb +191 -0
  67. data/lib/search_engine/collections_graph.rb +330 -0
  68. data/lib/search_engine/compiled_params.rb +143 -0
  69. data/lib/search_engine/compiler.rb +383 -0
  70. data/lib/search_engine/config/observability.rb +27 -0
  71. data/lib/search_engine/config/presets.rb +92 -0
  72. data/lib/search_engine/config/selection.rb +16 -0
  73. data/lib/search_engine/config/typesense.rb +48 -0
  74. data/lib/search_engine/config/validators.rb +97 -0
  75. data/lib/search_engine/config.rb +917 -0
  76. data/lib/search_engine/console_helpers.rb +130 -0
  77. data/lib/search_engine/deletion.rb +103 -0
  78. data/lib/search_engine/dispatcher.rb +125 -0
  79. data/lib/search_engine/dsl/parser.rb +582 -0
  80. data/lib/search_engine/engine.rb +167 -0
  81. data/lib/search_engine/errors.rb +290 -0
  82. data/lib/search_engine/filters/sanitizer.rb +189 -0
  83. data/lib/search_engine/hydration/materializers.rb +808 -0
  84. data/lib/search_engine/hydration/selection_context.rb +96 -0
  85. data/lib/search_engine/indexer/batch_planner.rb +76 -0
  86. data/lib/search_engine/indexer/bulk_import.rb +626 -0
  87. data/lib/search_engine/indexer/import_dispatcher.rb +198 -0
  88. data/lib/search_engine/indexer/retry_policy.rb +103 -0
  89. data/lib/search_engine/indexer.rb +747 -0
  90. data/lib/search_engine/instrumentation.rb +308 -0
  91. data/lib/search_engine/joins/guard.rb +202 -0
  92. data/lib/search_engine/joins/resolver.rb +95 -0
  93. data/lib/search_engine/logging/color.rb +78 -0
  94. data/lib/search_engine/logging/format_helpers.rb +92 -0
  95. data/lib/search_engine/logging/partition_progress.rb +53 -0
  96. data/lib/search_engine/logging_subscriber.rb +388 -0
  97. data/lib/search_engine/mapper.rb +785 -0
  98. data/lib/search_engine/multi.rb +286 -0
  99. data/lib/search_engine/multi_result.rb +186 -0
  100. data/lib/search_engine/notifications/compact_logger.rb +675 -0
  101. data/lib/search_engine/observability.rb +162 -0
  102. data/lib/search_engine/operations.rb +58 -0
  103. data/lib/search_engine/otel.rb +227 -0
  104. data/lib/search_engine/partitioner.rb +128 -0
  105. data/lib/search_engine/ranking_plan.rb +118 -0
  106. data/lib/search_engine/registry.rb +158 -0
  107. data/lib/search_engine/relation/compiler.rb +711 -0
  108. data/lib/search_engine/relation/deletion.rb +37 -0
  109. data/lib/search_engine/relation/dsl/filters.rb +624 -0
  110. data/lib/search_engine/relation/dsl/selection.rb +240 -0
  111. data/lib/search_engine/relation/dsl.rb +903 -0
  112. data/lib/search_engine/relation/dx/dry_run.rb +59 -0
  113. data/lib/search_engine/relation/dx/friendly_where.rb +24 -0
  114. data/lib/search_engine/relation/dx.rb +231 -0
  115. data/lib/search_engine/relation/materializers.rb +118 -0
  116. data/lib/search_engine/relation/options.rb +138 -0
  117. data/lib/search_engine/relation/state.rb +274 -0
  118. data/lib/search_engine/relation/updating.rb +44 -0
  119. data/lib/search_engine/relation.rb +623 -0
  120. data/lib/search_engine/result.rb +664 -0
  121. data/lib/search_engine/schema.rb +1083 -0
  122. data/lib/search_engine/sources/active_record_source.rb +185 -0
  123. data/lib/search_engine/sources/base.rb +62 -0
  124. data/lib/search_engine/sources/lambda_source.rb +55 -0
  125. data/lib/search_engine/sources/sql_source.rb +196 -0
  126. data/lib/search_engine/sources.rb +71 -0
  127. data/lib/search_engine/stale_rules.rb +160 -0
  128. data/lib/search_engine/test/minitest_assertions.rb +57 -0
  129. data/lib/search_engine/test/offline_client.rb +134 -0
  130. data/lib/search_engine/test/rspec_matchers.rb +77 -0
  131. data/lib/search_engine/test/stub_client.rb +201 -0
  132. data/lib/search_engine/test.rb +66 -0
  133. data/lib/search_engine/test_autoload.rb +8 -0
  134. data/lib/search_engine/update.rb +35 -0
  135. data/lib/search_engine/version.rb +7 -0
  136. data/lib/search_engine.rb +332 -0
  137. data/lib/tasks/search_engine.rake +501 -0
  138. data/lib/tasks/search_engine_doctor.rake +16 -0
  139. metadata +225 -0
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require 'active_support/inflector'
5
+ rescue LoadError
6
+ # ActiveSupport may not be available outside Rails; constant lookup will fallback
7
+ end
8
+
9
+ module SearchEngine
10
+ # Console-only helpers and installer for the `SE` top-level shortcut.
11
+ #
12
+ # In Rails console, the engine installs `::SE` so you can quickly build and
13
+ # run queries interactively.
14
+ module ConsoleHelpers
15
+ # Install the top-level `SE` constant unless already defined.
16
+ # @return [void]
17
+ def self.install!
18
+ return if Object.const_defined?(:SE)
19
+
20
+ Object.const_set(:SE, HelpersModule)
21
+ nil
22
+ end
23
+
24
+ # Internal module backing the `SE` constant.
25
+ module HelpersModule
26
+ module_function
27
+
28
+ # Run a simple search on a default model with optional overrides.
29
+ #
30
+ # Default model resolution:
31
+ # - Prefer `SearchEngine.config.default_console_model` (Class or String)
32
+ # - Fallback to the sole registered model in the collection registry
33
+ # - Raise a helpful error if ambiguous or none are found
34
+ #
35
+ # @param query [String, nil]
36
+ # @param opts [Hash] common options (e.g., select:, per:, page:, where:, query_by:, ...)
37
+ # @return [SearchEngine::Relation]
38
+ # @example
39
+ # SE.q('milk').per(5)
40
+ # SE.q.where(category: 'dairy')
41
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/dx
42
+ def q(query = nil, **opts)
43
+ model = default_model!
44
+ rel = model.all
45
+ rel = rel.options(q: query) unless query.nil?
46
+
47
+ select_opt = opts.delete(:select)
48
+ rel = rel.select(*Array(select_opt)) if select_opt
49
+
50
+ where_opt = opts.delete(:where)
51
+ rel = rel.where(where_opt) if where_opt
52
+
53
+ per_opt = opts.delete(:per) || opts.delete(:per_page)
54
+ rel = rel.per(per_opt) if per_opt
55
+
56
+ page_opt = opts.delete(:page)
57
+ rel = rel.page(page_opt) if page_opt
58
+
59
+ # Pass remaining options (e.g., query_by:, preset:, grouping)
60
+ rel.options(opts)
61
+ end
62
+
63
+ # Multi-search convenience wrapper that delegates to SearchEngine.multi_search.
64
+ # @param common [Hash]
65
+ # @yieldparam m [SearchEngine::Multi]
66
+ # @return [SearchEngine::Multi::ResultSet]
67
+ # @example
68
+ # SE.ms { |m| m.add :products, SE.q('milk').per(5) }
69
+ def ms(common: {}, &block)
70
+ SearchEngine.multi_search(common: common, &block)
71
+ end
72
+
73
+ # Return a base relation for the default (or provided) model.
74
+ # @param model [Class, nil]
75
+ # @return [SearchEngine::Relation]
76
+ def rel(model = nil)
77
+ (model || default_model!).all
78
+ end
79
+
80
+ # Resolve the default model, honoring configuration and registry.
81
+ # @return [Class]
82
+ # @raise [ArgumentError] with hint and docs link when ambiguous or missing
83
+ def default_model!
84
+ cfg = SearchEngine.config
85
+ if cfg.respond_to?(:default_console_model) && cfg.default_console_model
86
+ return resolve_model_class(cfg.default_console_model)
87
+ end
88
+
89
+ mapping = SearchEngine::Registry.mapping
90
+ if mapping.empty?
91
+ raise ArgumentError,
92
+ 'No default model configured. Set SearchEngine.config.default_console_model ' \
93
+ 'or define a single SearchEngine::Base model. See ' \
94
+ 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/dx#generators--console-helpers.'
95
+ end
96
+
97
+ uniq_klasses = mapping.values.uniq
98
+ return uniq_klasses.first if uniq_klasses.size == 1
99
+
100
+ names = uniq_klasses.map { |k| k.respond_to?(:name) && k.name ? k.name : k.to_s }.sort
101
+ raise ArgumentError,
102
+ "Ambiguous default model: #{names.join(', ')}. Set SearchEngine.config.default_console_model. " \
103
+ 'See https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/dx#generators--console-helpers.'
104
+ end
105
+
106
+ def resolve_model_class(value)
107
+ return value if value.is_a?(Class) && value < SearchEngine::Base
108
+
109
+ name =
110
+ case value
111
+ when Symbol then value.to_s
112
+ when String then value
113
+ else value.to_s
114
+ end
115
+
116
+ if defined?(ActiveSupport::Inflector)
117
+ Object.const_get(name)
118
+ else
119
+ name.split('::').reduce(Object) { |mod, part| mod.const_get(part) }
120
+ end
121
+ rescue NameError
122
+ raise ArgumentError,
123
+ "Unknown model constant #{name.inspect} for default_console_model. Ensure it's loaded. " \
124
+ 'See https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/dx#generators--console-helpers.'
125
+ end
126
+
127
+ private_class_method :resolve_model_class
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ # Shared deletion helpers for building filters and deleting documents
5
+ # across both the mapper DSL and model-level APIs.
6
+ module Deletion
7
+ module_function
8
+
9
+ # Delete documents by filter string or hash from the physical collection
10
+ # resolved for the given klass and optional partition.
11
+ #
12
+ # @param klass [Class] a SearchEngine::Base subclass
13
+ # @param filter [String, nil] Typesense filter string (takes precedence over hash)
14
+ # @param hash [Hash, nil] Hash converted to a filter string via Sanitizer
15
+ # @param into [String, nil] explicit physical collection name override
16
+ # @param partition [Object, nil] partition token for resolvers
17
+ # @param timeout_ms [Integer, nil] optional read timeout override in ms
18
+ # @return [Integer] number of deleted documents as reported by Typesense
19
+ def delete_by(klass:, filter: nil, hash: nil, into: nil, partition: nil, timeout_ms: nil)
20
+ filter_str = build_filter(filter, hash)
21
+ collection = resolve_into(klass: klass, partition: partition, into: into)
22
+
23
+ effective_timeout = if timeout_ms&.to_i&.positive?
24
+ timeout_ms.to_i
25
+ else
26
+ begin
27
+ SearchEngine.config.stale_deletes&.timeout_ms
28
+ rescue StandardError
29
+ nil
30
+ end
31
+ end
32
+
33
+ resp = SearchEngine.client.delete_documents_by_filter(
34
+ collection: collection,
35
+ filter_by: filter_str,
36
+ timeout_ms: effective_timeout
37
+ )
38
+ (resp && (resp[:num_deleted] || resp[:deleted] || resp[:numDeleted])).to_i
39
+ end
40
+
41
+ # Build a Typesense filter string from either a string or a hash.
42
+ # @param filter [String, nil]
43
+ # @param hash [Hash, nil]
44
+ # @return [String]
45
+ def build_filter(filter, hash)
46
+ if filter && !filter.to_s.strip.empty?
47
+ filter.to_s
48
+ elsif hash.is_a?(Hash) && !hash.empty?
49
+ fragments = SearchEngine::Filters::Sanitizer.build_from_hash(hash)
50
+ fragments.join(' && ')
51
+ else
52
+ raise ArgumentError, 'delete_by requires a filter string or a non-empty hash'
53
+ end
54
+ end
55
+
56
+ # Resolve the physical collection name using the same logic as the indexer.
57
+ # @param klass [Class]
58
+ # @param partition [Object, nil]
59
+ # @param into [String, nil]
60
+ # @return [String]
61
+ def resolve_into(klass:, partition:, into:)
62
+ return into if into && !into.to_s.strip.empty?
63
+
64
+ # Prefer a context-provided target collection when present (e.g., inside
65
+ # indexer apply/partial runs). This ensures that delete_by calls inside
66
+ # before/after hooks target the correct physical collection, avoiding 404s
67
+ # during the initial apply before the alias exists.
68
+ begin
69
+ ctx_into = SearchEngine::Instrumentation.context[:into]
70
+ return ctx_into if ctx_into && !ctx_into.to_s.strip.empty?
71
+ rescue StandardError
72
+ # fall through to default resolution
73
+ end
74
+
75
+ resolver = begin
76
+ SearchEngine.config.partitioning&.default_into_resolver
77
+ rescue StandardError
78
+ nil
79
+ end
80
+
81
+ if resolver.respond_to?(:arity)
82
+ case resolver.arity
83
+ when 1
84
+ val = resolver.call(klass)
85
+ return val if val && !val.to_s.strip.empty?
86
+ when 2, -1
87
+ val = resolver.call(klass, partition)
88
+ return val if val && !val.to_s.strip.empty?
89
+ end
90
+ elsif resolver
91
+ val = resolver.call(klass)
92
+ return val if val && !val.to_s.strip.empty?
93
+ end
94
+
95
+ name = if klass.respond_to?(:collection)
96
+ klass.collection
97
+ else
98
+ klass.name.to_s
99
+ end
100
+ name.to_s
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ # Dispatcher routes per-partition rebuilds either synchronously (inline) or via ActiveJob.
5
+ #
6
+ # Public API:
7
+ # - {.dispatch!(klass, partition:, into: nil, mode: nil, queue: nil, metadata: {})}
8
+ # Returns a descriptor of what happened (enqueued job id / inline summary, mode used).
9
+ module Dispatcher
10
+ # Dispatch a single partition rebuild.
11
+ #
12
+ # @param klass [Class] model class inheriting from {SearchEngine::Base}
13
+ # @param partition [Object] opaque partition key
14
+ # @param into [String, nil] optional target collection (physical or alias)
15
+ # @param mode [Symbol, String, nil] :active_job or :inline; falls back to config
16
+ # @param queue [String, nil] ActiveJob queue override
17
+ # @param metadata [Hash] small, JSON-safe tracing values
18
+ # @return [Hash] descriptor of the action performed
19
+ def self.dispatch!(klass, partition:, into: nil, mode: nil, queue: nil, metadata: {})
20
+ unless klass.is_a?(Class)
21
+ raise SearchEngine::Errors::InvalidParams.new(
22
+ 'klass must be a Class',
23
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/indexer#troubleshooting',
24
+ details: { arg: :klass }
25
+ )
26
+ end
27
+ unless klass.ancestors.include?(SearchEngine::Base)
28
+ raise SearchEngine::Errors::InvalidParams.new(
29
+ 'klass must inherit from SearchEngine::Base',
30
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/indexer#troubleshooting',
31
+ details: { klass: klass.to_s }
32
+ )
33
+ end
34
+
35
+ effective_mode = resolve_mode(mode)
36
+ case effective_mode
37
+ when :active_job
38
+ dispatch_active_job!(klass, partition: partition, into: into, queue: queue, metadata: metadata)
39
+ else
40
+ dispatch_inline!(klass, partition: partition, into: into, metadata: metadata)
41
+ end
42
+ end
43
+
44
+ class << self
45
+ private
46
+
47
+ def resolve_mode(override)
48
+ m = (override || SearchEngine.config.indexer.dispatch || :inline).to_sym
49
+ return :active_job if m == :active_job && defined?(::ActiveJob::Base)
50
+
51
+ :inline
52
+ end
53
+
54
+ def dispatch_active_job!(klass, partition:, into:, queue:, metadata:)
55
+ q = (queue || SearchEngine.config.indexer.queue_name || 'search_index').to_s
56
+ class_name = klass.name.to_s
57
+ job = SearchEngine::IndexPartitionJob
58
+ .set(queue: q)
59
+ .perform_later(class_name, partition, into: into, metadata: metadata || {})
60
+ payload = {
61
+ collection: safe_collection_name(klass),
62
+ partition: partition,
63
+ into: into,
64
+ queue: q,
65
+ job_id: job.job_id
66
+ }
67
+ instrument('search_engine.dispatcher.enqueued', payload)
68
+ {
69
+ mode: :active_job,
70
+ collection: payload[:collection],
71
+ partition: partition,
72
+ into: into,
73
+ queue: q,
74
+ job_id: job.job_id
75
+ }
76
+ end
77
+
78
+ def dispatch_inline!(klass, partition:, into:, metadata:)
79
+ started = monotonic_ms
80
+ payload = {
81
+ collection: safe_collection_name(klass),
82
+ partition: partition,
83
+ into: into,
84
+ metadata: metadata
85
+ }
86
+ instrument('search_engine.dispatcher.inline_started', payload)
87
+ summary = nil
88
+ SearchEngine::Instrumentation.with_context(dispatch_mode: :inline) do
89
+ summary = SearchEngine::Indexer.rebuild_partition!(klass, partition: partition, into: into)
90
+ end
91
+ duration = (monotonic_ms - started).round(1)
92
+ instrument(
93
+ 'search_engine.dispatcher.inline_finished',
94
+ payload.merge(duration_ms: duration, status: summary.status)
95
+ )
96
+ {
97
+ mode: :inline,
98
+ collection: payload[:collection],
99
+ partition: partition,
100
+ into: into,
101
+ indexer_summary: summary,
102
+ duration_ms: duration
103
+ }
104
+ rescue StandardError => error
105
+ instrument(
106
+ 'search_engine.dispatcher.inline_error',
107
+ payload.merge(error_class: error.class.name, message_truncated: error.message.to_s[0, 200])
108
+ )
109
+ raise
110
+ end
111
+
112
+ def instrument(event, payload)
113
+ SearchEngine::Instrumentation.instrument(event, payload) {}
114
+ end
115
+
116
+ def safe_collection_name(klass)
117
+ klass.respond_to?(:collection) ? klass.collection.to_s : klass.name.to_s
118
+ end
119
+
120
+ def monotonic_ms
121
+ SearchEngine::Instrumentation.monotonic_ms
122
+ end
123
+ end
124
+ end
125
+ end