From 69f69bbb10277089ccbb74f30f05eddda04cba44 Mon Sep 17 00:00:00 2001 From: Ugo Nnanna Okeadu Date: Mon, 20 Oct 2025 08:24:51 +0200 Subject: [PATCH 1/2] Add ES DSL field implementation --- ee/lib/search/elastic/dsl/field.rb | 104 ++++++++++ ee/spec/lib/search/elastic/dsl/field_spec.rb | 196 +++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 ee/lib/search/elastic/dsl/field.rb create mode 100644 ee/spec/lib/search/elastic/dsl/field_spec.rb diff --git a/ee/lib/search/elastic/dsl/field.rb b/ee/lib/search/elastic/dsl/field.rb new file mode 100644 index 00000000000000..43c6106878f30f --- /dev/null +++ b/ee/lib/search/elastic/dsl/field.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +# -------------------------------------------------------------------------- +# Search::Elastic::Dsl::Field +# -------------------------------------------------------------------------- +# Provides a domain-specific language (DSL) for declaring Elasticsearch +# indexable fields within model or schema classes. +# +# Features: +# - Flat fields: field :title, type: :keyword +# - Nested fields: field(:scanner, type: :object) { field :id, type: :keyword } +# - Computed fields: field :hash, compute: ->(r) { r.data_hash } +# - Default values: field :status, default: 'active' +# - Conditional fields: field :foo, if: -> { Feature.enabled?(:foo_field) } +# - Versioned fields: field :new_column, version: 2525 +# - Enrichment fields: field :project_name, enrich: ->(ids) { ... } +# +# Implementation details: +# - Each field definition becomes a Hash (see `build_node`). +# - All fields are stored in a class-level registry (`fields_registry`). +# - Nested fields are defined recursively within their parent's block. +# - Errors during DSL definition are tracked with Gitlab::ErrorTracking. +# -------------------------------------------------------------------------- +module Search + module Elastic + module Dsl + module Field + extend ActiveSupport::Concern + + included do + # Registry for all declared fields for this class + class_attribute :fields_registry, instance_accessor: false, default: {} + end + + class_methods do + # Declare a field and merge it into the class registry + def field(name, **opts, &block) + node = build_node(name, **opts, &block) + + # Merge immutably into class-level registry + self.fields_registry = fields_registry.merge(name.to_sym => node) + rescue StandardError => e + ::Gitlab::ErrorTracking.track_exception(e, class: name) + end + + private + + # Build a field node hash + def build_node(name, **opts, &block) + { + name: name, + type: opts[:type], + enrich: opts[:enrich] || opts[:preload], + compute: opts[:compute], + default: opts[:default], + version: opts[:version], + condition: opts[:if], + nested: block ? build_nested(&block) : nil + } + rescue StandardError => e + ::Gitlab::ErrorTracking.track_exception(e, class: name) + {} + end + + # Build nested fields using a temporary DSL context + def build_nested(&block) + context = NestedFieldContext.new(self) + context.instance_eval(&block) + context.nested_fields + rescue StandardError => e + ::Gitlab::ErrorTracking.track_exception(e, class: name) + {} + end + end + + # -------------------------------------------------------------------- + # NestedFieldContext + # -------------------------------------------------------------------- + # DSL context for nested fields + # -------------------------------------------------------------------- + class NestedFieldContext + attr_reader :nested_fields + + def initialize(builder) + @builder = builder + @nested_fields = {} + end + + # Define nested field + def field(name, **opts, &block) + # Use class_exec instead of .send + node = @builder.class_exec(name, opts, block) do |n, o, blk| + build_node(n, **o, &blk) + end + + @nested_fields[name.to_sym] = node + rescue StandardError => e + ::Gitlab::ErrorTracking.track_exception(e, class: @builder.name) + end + end + end + end + end +end diff --git a/ee/spec/lib/search/elastic/dsl/field_spec.rb b/ee/spec/lib/search/elastic/dsl/field_spec.rb new file mode 100644 index 00000000000000..0b541fd2c5f844 --- /dev/null +++ b/ee/spec/lib/search/elastic/dsl/field_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Search::Elastic::Dsl::Field, feature_category: :global_search do + # - Anonymous class to include the DSL + # - This simulates a model declaring indexable fields + let(:klass) do + Class.new do + include Search::Elastic::Dsl::Field + + class << self + attr_accessor :fields_registry + end + + self.fields_registry = {} + end + end + + describe '.field' do + it 'registers a simple flat field in the registry' do + klass.field(:title, type: :keyword) + + expect(klass.fields_registry).to include( + title: hash_including( + name: :title, + type: :keyword, + nested: nil + ) + ) + end + + it 'supports compute, default, enrich, version, and condition options' do + compute_lambda = ->(r) { r.name.upcase } + default_lambda = -> { 'fallback' } + + klass.field( + :name, + type: :keyword, + compute: compute_lambda, + default: default_lambda, + enrich: true, + version: 5, + if: -> { true } + ) + + node = klass.fields_registry[:name] + expect(node).to include( + name: :name, + type: :keyword, + compute: compute_lambda, + default: default_lambda, + enrich: true, + version: 5, + condition: an_instance_of(Proc) + ) + end + + it 'defines deeply nested fields with multiple levels' do + klass.field(:scanner, type: :object) do + field :external_id, type: :keyword + field :details, type: :object do + field :vendor, type: :keyword + end + end + + root = klass.fields_registry[:scanner] + expect(root[:type]).to eq(:object) + expect(root[:nested]).to include(:external_id, :details) + expect(root[:nested][:details][:nested]).to include(:vendor) + end + + it 'tracks errors in Gitlab::ErrorTracking when DSL definition fails' do + allow(klass).to receive(:build_node).and_raise(StandardError, 'bad field') + allow(Gitlab::ErrorTracking).to receive(:track_exception) + + klass.field(:broken, type: :text) + expect(Gitlab::ErrorTracking).to have_received(:track_exception).once + end + end + + describe 'nested fields with advanced options' do + it 'handles nested computed fields correctly' do + compute_lambda = ->(_r) { 'computed-value' } + + klass.field(:metadata, type: :object) do + field :fingerprint, type: :keyword, compute: compute_lambda + end + + nested = klass.fields_registry[:metadata][:nested] + expect(nested[:fingerprint][:compute]).to eq(compute_lambda) + expect(nested[:fingerprint][:type]).to eq(:keyword) + end + + it 'includes nested fields with defaults and conditions' do + klass.field(:details, type: :object) do + field :vendor, type: :keyword, default: 'gitlab' + field :enabled, type: :boolean, if: -> { false } + end + + nested = klass.fields_registry[:details][:nested] + expect(nested[:vendor][:default]).to eq('gitlab') + expect(nested[:enabled][:condition]).to be_a(Proc) + end + + it 'registers versioned nested fields' do + klass.field(:metadata, type: :object) do + field :versioned_key, type: :keyword, version: 10 + end + + nested = klass.fields_registry[:metadata][:nested] + expect(nested[:versioned_key][:version]).to eq(10) + end + + it 'allows combining nested compute, default, and enrich' do + compute_lambda = ->(r) { "computed-#{r.id}" } + + klass.field(:context, type: :object) do + field :cid, type: :keyword, compute: compute_lambda, default: 'no-id', enrich: true + end + + nested = klass.fields_registry[:context][:nested] + expect(nested[:cid]).to include( + type: :keyword, + compute: compute_lambda, + default: 'no-id', + enrich: true + ) + end + end + + describe '.build_node' do + it 'returns a well-formed node hash with expected keys' do + node = klass.send(:build_node, :id, type: :integer, default: 42) + expect(node.keys).to include(:name, :type, :enrich, :compute, :default, :version, :condition, :nested) + expect(node[:name]).to eq(:id) + expect(node[:default]).to eq(42) + end + + it 'builds nested field structures when block is passed' do + node = klass.send(:build_node, :object, type: :object) do + field :child, type: :keyword + end + + expect(node[:nested]).to include(:child) + expect(node[:nested][:child][:type]).to eq(:keyword) + end + end + + describe '.build_nested' do + it 'builds nested fields using a lightweight DSL context' do + nested = klass.send(:build_nested) do + field :external_id, type: :keyword + field :scanner_type, type: :text + end + + expect(nested.keys).to contain_exactly(:external_id, :scanner_type) + expect(nested[:external_id][:type]).to eq(:keyword) + end + + it 'handles nested blocks recursively' do + nested = klass.send(:build_nested) do + field :parent, type: :object do + field :child, type: :keyword + end + end + + expect(nested[:parent][:nested]).to include(:child) + end + + it 'tracks and skips broken nested field definitions but continues building others' do + allow(Gitlab::ErrorTracking).to receive(:track_exception) + + nested = klass.send(:build_nested) do + field :valid_field, type: :keyword + field :broken_field, type: :keyword do + raise 'invalid nested field' + end + end + + expect(nested).to include(:valid_field) + expect(Gitlab::ErrorTracking).to have_received(:track_exception).once + end + + it 'logs errors if nested block itself raises' do + allow(Gitlab::ErrorTracking).to receive(:track_exception) + + result = klass.send(:build_nested) do + raise 'top-level nested failure' + end + + expect(result).to eq({}) + expect(Gitlab::ErrorTracking).to have_received(:track_exception).once + end + end +end -- GitLab From 54529f6ee3de4c6f395e0130b55e150ba396f9c0 Mon Sep 17 00:00:00 2001 From: Ugo Nnanna Okeadu Date: Wed, 22 Oct 2025 19:16:59 +0200 Subject: [PATCH 2/2] Align comments in DSL --- ee/lib/search/elastic/dsl/field.rb | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/ee/lib/search/elastic/dsl/field.rb b/ee/lib/search/elastic/dsl/field.rb index 43c6106878f30f..9a76a5ce24c454 100644 --- a/ee/lib/search/elastic/dsl/field.rb +++ b/ee/lib/search/elastic/dsl/field.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true -# -------------------------------------------------------------------------- # Search::Elastic::Dsl::Field -# -------------------------------------------------------------------------- +# # Provides a domain-specific language (DSL) for declaring Elasticsearch # indexable fields within model or schema classes. # @@ -20,7 +19,6 @@ # - All fields are stored in a class-level registry (`fields_registry`). # - Nested fields are defined recursively within their parent's block. # - Errors during DSL definition are tracked with Gitlab::ErrorTracking. -# -------------------------------------------------------------------------- module Search module Elastic module Dsl @@ -73,11 +71,9 @@ def build_nested(&block) end end - # -------------------------------------------------------------------- # NestedFieldContext - # -------------------------------------------------------------------- + # # DSL context for nested fields - # -------------------------------------------------------------------- class NestedFieldContext attr_reader :nested_fields -- GitLab