odata_duty 0.30.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 (51) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +312 -0
  3. data/lib/generators/odata_duty/entity_set/entity_set_generator.rb +56 -0
  4. data/lib/generators/odata_duty/entity_set/templates/entity_set.rb.erb +20 -0
  5. data/lib/generators/odata_duty/entity_set/templates/entity_set_spec.rb.erb +38 -0
  6. data/lib/generators/odata_duty/entity_set/templates/entity_type.rb.erb +6 -0
  7. data/lib/generators/odata_duty/entity_set/templates/entity_type_spec.rb.erb +20 -0
  8. data/lib/generators/odata_duty/entity_set/templates/odata_active_record_concern.rb.erb +109 -0
  9. data/lib/generators/odata_duty/entity_set/templates/resolver.rb.erb +39 -0
  10. data/lib/generators/odata_duty/entity_set/templates/resolver_spec.rb.erb +50 -0
  11. data/lib/generators/odata_duty/install/install_generator.rb +60 -0
  12. data/lib/generators/odata_duty/install/templates/controller.rb.tt +43 -0
  13. data/lib/generators/odata_duty/install/templates/schema.rb.tt +16 -0
  14. data/lib/metadata.xml.erb +116 -0
  15. data/lib/odata_duty/complex_type.rb +70 -0
  16. data/lib/odata_duty/context_wrapper.rb +26 -0
  17. data/lib/odata_duty/create_complex_type_hash_wrapper.rb +49 -0
  18. data/lib/odata_duty/dynamic_object_wrapper.rb.erb +79 -0
  19. data/lib/odata_duty/edms.rb +122 -0
  20. data/lib/odata_duty/edmx_schema.rb +19 -0
  21. data/lib/odata_duty/entity_type.rb +72 -0
  22. data/lib/odata_duty/enum_type.rb +71 -0
  23. data/lib/odata_duty/errors.rb +52 -0
  24. data/lib/odata_duty/executor.rb +277 -0
  25. data/lib/odata_duty/filter.rb +76 -0
  26. data/lib/odata_duty/filter_predicate.rb +12 -0
  27. data/lib/odata_duty/mapper_builder.rb +86 -0
  28. data/lib/odata_duty/mcp_input_schemas.rb +59 -0
  29. data/lib/odata_duty/mcp_server_builder.rb +107 -0
  30. data/lib/odata_duty/oas2/collection_get_path.rb +97 -0
  31. data/lib/odata_duty/oas2/collection_post_path.rb +63 -0
  32. data/lib/odata_duty/oas2/individual_delete_path.rb +42 -0
  33. data/lib/odata_duty/oas2/individual_get_path.rb +37 -0
  34. data/lib/odata_duty/oas2/individual_patch_path.rb +63 -0
  35. data/lib/odata_duty/oas2.rb +113 -0
  36. data/lib/odata_duty/parslet_search_expression.rb +163 -0
  37. data/lib/odata_duty/property/collection_prop.rb +25 -0
  38. data/lib/odata_duty/property/single_prop.rb +131 -0
  39. data/lib/odata_duty/property.rb +49 -0
  40. data/lib/odata_duty/railtie.rb +11 -0
  41. data/lib/odata_duty/schema_builder/complex_type.rb +34 -0
  42. data/lib/odata_duty/schema_builder/container.rb +16 -0
  43. data/lib/odata_duty/schema_builder/data_type.rb +18 -0
  44. data/lib/odata_duty/schema_builder/endpoint.rb +117 -0
  45. data/lib/odata_duty/schema_builder/entity_set.rb +67 -0
  46. data/lib/odata_duty/schema_builder/entity_type.rb +49 -0
  47. data/lib/odata_duty/schema_builder/enum_type.rb +32 -0
  48. data/lib/odata_duty/schema_builder.rb +130 -0
  49. data/lib/odata_duty/set_resolver.rb +51 -0
  50. data/lib/odata_duty.rb +311 -0
  51. metadata +168 -0
@@ -0,0 +1,107 @@
1
+ require 'mcp'
2
+ require 'odata_duty/mcp_input_schemas'
3
+
4
+ module OdataDuty
5
+ module McpServerBuilder
6
+ extend self
7
+
8
+ def build(schema)
9
+ server = MCP::Server.new(
10
+ name: schema.title,
11
+ version: schema.version,
12
+ capabilities: { tools: {} }
13
+ )
14
+ schema.endpoints.each { |endpoint| register_endpoint_tools(server, schema, endpoint) }
15
+ server
16
+ end
17
+
18
+ def register_endpoint_tools(server, schema, endpoint)
19
+ register_collection_tools(server, schema, endpoint) if endpoint.supports_collection?
20
+ register_get_tool(server, schema, endpoint) if endpoint.supports_individual?
21
+ register_create_tool(server, schema, endpoint) if endpoint.supports_create?
22
+ register_update_tool(server, schema, endpoint) if endpoint.supports_update?
23
+ register_delete_tool(server, schema, endpoint) if endpoint.supports_delete?
24
+ end
25
+
26
+ def register_collection_tools(server, schema, endpoint)
27
+ register_list_tool(server, schema, endpoint)
28
+ register_count_tool(server, schema, endpoint) if endpoint.supports_count?
29
+ end
30
+
31
+ def register_update_tool(server, schema, endpoint)
32
+ register_key_tool(server, schema, endpoint, :update, 'Update an existing')
33
+ end
34
+
35
+ def register_delete_tool(server, schema, endpoint)
36
+ register_key_tool(server, schema, endpoint, :delete, 'Delete an existing')
37
+ end
38
+
39
+ def register_list_tool(server, schema, endpoint)
40
+ input_schema = McpInputSchemas.list_input_schema(supports_search: endpoint.supports_search?)
41
+ define_tool(server, schema, :execute,
42
+ url_for: ->(_args) { endpoint.url },
43
+ name: "list_#{endpoint.name}",
44
+ description: "List #{endpoint.name} records", input_schema: input_schema)
45
+ end
46
+
47
+ def register_count_tool(server, schema, endpoint)
48
+ input_schema = McpInputSchemas.count_input_schema(supports_search: endpoint.supports_search?)
49
+ define_tool(server, schema, :execute,
50
+ url_for: ->(_args) { "#{endpoint.url}/$count" },
51
+ name: "count_#{endpoint.name}",
52
+ description: "Count #{endpoint.name} records", input_schema: input_schema)
53
+ end
54
+
55
+ def register_create_tool(server, schema, endpoint)
56
+ define_tool(server, schema, :create,
57
+ url_for: ->(_args) { endpoint.url },
58
+ name: "create_#{endpoint.name}",
59
+ description: "Create a new #{endpoint.name} record",
60
+ input_schema: McpInputSchemas.create_input_schema(endpoint.entity_type))
61
+ end
62
+
63
+ def register_get_tool(server, schema, endpoint)
64
+ define_tool(server, schema, :execute,
65
+ url_for: keyed_url_for(endpoint),
66
+ name: "get_#{endpoint.name}",
67
+ description: "Get a single #{endpoint.name} record by ID",
68
+ input_schema: McpInputSchemas.get_input_schema(endpoint.entity_type))
69
+ end
70
+
71
+ def register_key_tool(server, schema, endpoint, action, verb)
72
+ input_schema = McpInputSchemas.public_send("#{action}_input_schema", endpoint.entity_type)
73
+ define_tool(server, schema, action,
74
+ url_for: keyed_url_for(endpoint),
75
+ name: "#{action}_#{endpoint.name}",
76
+ description: "#{verb} #{endpoint.name} record", input_schema: input_schema)
77
+ end
78
+
79
+ # Builds the `<url>('<key>')` locator from the tool arguments. The dynamic `args[key]` lookup
80
+ # has no mutation-testable equivalent (the key is always present, enforced by the SDK's
81
+ # required-argument check), so this is the one MCP subject left on the .mutant.yml ignore list.
82
+ def keyed_url_for(endpoint)
83
+ key = endpoint.entity_type.property_refs.first.name
84
+ ->(args) { "#{endpoint.url}('#{args[key]}')" }
85
+ end
86
+
87
+ # On the .mutant.yml ignore list: `server_context[:context]` has only equivalent mutants
88
+ # (`[]` vs `fetch`/`dig`); the key is always present, so no public-API test distinguishes them.
89
+ def define_tool(server, schema, action, url_for:, **tool_args)
90
+ server.define_tool(**tool_args) do |server_context:, **args|
91
+ McpServerBuilder.run_tool(action, url: url_for.call(args), schema: schema,
92
+ context: server_context[:context],
93
+ query_options: args.transform_keys(&:to_s))
94
+ end
95
+ end
96
+
97
+ # On the .mutant.yml ignore list: `e.message` has only an equivalent mutant (`e`), since an
98
+ # OdataDuty::Error renders identically to its message in the text content block.
99
+ def run_tool(action, url:, schema:, context:, query_options:)
100
+ result = Executor.public_send(action, url: url, context: context,
101
+ query_options: query_options, schema: schema)
102
+ MCP::Tool::Response.new([{ type: 'text', text: result.to_s }])
103
+ rescue OdataDuty::Error => e
104
+ MCP::Tool::Response.new([{ type: 'text', text: e.message }], error: true)
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,97 @@
1
+ module OdataDuty
2
+ class OAS2
3
+ CollectionGetPath = Struct.new(:entity_set, :context) do
4
+ COLLECTION_PARAMETERS = [
5
+ {
6
+ 'name' => '$filter',
7
+ 'in' => 'query',
8
+ 'type' => 'string',
9
+ 'description' => 'Filter the results, supporting `and` and flat `or` combinations'
10
+ },
11
+ {
12
+ 'name' => '$search',
13
+ 'in' => 'query',
14
+ 'type' => 'string',
15
+ 'description' => 'Search using structured expressions with AND, OR, NOT operators'
16
+ },
17
+ {
18
+ 'name' => '$select',
19
+ 'in' => 'query',
20
+ 'type' => 'array',
21
+ 'items' => { 'type' => 'string' },
22
+ 'collectionFormat' => 'csv',
23
+ 'description' => 'Comma-separated list of properties to return'
24
+ },
25
+ {
26
+ 'name' => '$top',
27
+ 'in' => 'query',
28
+ 'type' => 'integer',
29
+ 'description' => 'Number of results to return'
30
+ },
31
+ {
32
+ 'name' => '$skip',
33
+ 'in' => 'query',
34
+ 'type' => 'integer',
35
+ 'description' => 'Number of results to skip'
36
+ },
37
+ {
38
+ 'name' => '$count',
39
+ 'in' => 'query',
40
+ 'type' => 'boolean',
41
+ 'description' => 'Include count of the results'
42
+ },
43
+ {
44
+ 'name' => '$skiptoken',
45
+ 'in' => 'query',
46
+ 'type' => 'string',
47
+ 'description' => 'Token for next page of results'
48
+ }
49
+ ].freeze
50
+ COLLECTION_RESPONSE_DEFAULTS = {
51
+ '@odata.nextLink' => {
52
+ 'type' => 'string',
53
+ 'description' => 'Url for next page of results',
54
+ 'x-nullable' => true
55
+ },
56
+ '@odata.count' => {
57
+ 'type' => 'integer',
58
+ 'description' => 'Total count of results, if $count set to true',
59
+ 'x-nullable' => true
60
+ }
61
+ }.freeze
62
+
63
+ PARAMETER_REQUIREMENTS = {
64
+ '$top' => :od_top,
65
+ '$count' => :count,
66
+ '$skip' => :od_skip,
67
+ '$skiptoken' => :od_skiptoken,
68
+ '$search' => :od_search
69
+ }.freeze
70
+
71
+ def to_oas2
72
+ instance = entity_set.resolver_class.new(context: context, init_args: entity_set.init_args)
73
+ parameters = COLLECTION_PARAMETERS.select do |param|
74
+ !PARAMETER_REQUIREMENTS.key?(param['name']) ||
75
+ instance.respond_to?(PARAMETER_REQUIREMENTS[param['name']])
76
+ end
77
+ {
78
+ 'operationId' => "GetCollectionOf#{entity_set.name}",
79
+ 'produces' => ['application/json'],
80
+ 'parameters' => parameters,
81
+ 'responses' => { '200' => oas2_success_response, 'default' => DEFAULT_ERROR_RESPONSE }
82
+ }
83
+ end
84
+
85
+ def oas2_success_response
86
+ { 'description' => 'Collection Response',
87
+ 'schema' => {
88
+ 'type' => 'object',
89
+ 'properties' => { 'value' => {
90
+ 'type' => 'array',
91
+ 'items' => { '$ref' => "#/definitions/#{entity_set.entity_type_name}" }
92
+ } }.merge(COLLECTION_RESPONSE_DEFAULTS)
93
+ } }
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,63 @@
1
+ module OdataDuty
2
+ class OAS2
3
+ class CollectionPostPath
4
+ def self.to_oas2(entity_set)
5
+ path_info = new(entity_set)
6
+ {
7
+ 'operationId' => path_info.operation_id,
8
+ 'produces' => path_info.produces,
9
+ 'parameters' => path_info.parameters,
10
+ 'responses' => path_info.responses
11
+ }
12
+ end
13
+
14
+ def self.request_body_definition(entity_set)
15
+ writable = entity_set.entity_type.properties.select(&:settable_on_create?)
16
+ definition = { 'type' => 'object',
17
+ 'properties' => writable.to_h { |p| [p.name.to_s, p.to_oas2] } }
18
+ required = writable.reject(&:nullable).map { |p| p.name.to_s }
19
+ definition['required'] = required unless required.empty?
20
+ ["#{entity_set.entity_type.name}Create", definition]
21
+ end
22
+
23
+ def initialize(entity_set)
24
+ @entity_set = entity_set
25
+ end
26
+
27
+ def operation_id
28
+ "Create#{@entity_set.name}"
29
+ end
30
+
31
+ def produces
32
+ ['application/json']
33
+ end
34
+
35
+ def parameters
36
+ [
37
+ {
38
+ 'name' => 'body', 'in' => 'body', 'required' => true, 'schema' => create_schema
39
+ }
40
+ ]
41
+ end
42
+
43
+ def responses
44
+ {
45
+ '200' => { 'description' => 'Success', 'schema' => entity_type_schema },
46
+ '201' => { 'description' => 'Created', 'schema' => entity_type_schema },
47
+ 'default' => { 'description' => 'Unexpected error',
48
+ 'schema' => { '$ref' => '#/definitions/Error' } }
49
+ }
50
+ end
51
+
52
+ private
53
+
54
+ def entity_type_schema
55
+ { '$ref' => "#/definitions/#{@entity_set.entity_type.name}" }
56
+ end
57
+
58
+ def create_schema
59
+ { '$ref' => "#/definitions/#{@entity_set.entity_type.name}Create" }
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,42 @@
1
+ module OdataDuty
2
+ class OAS2
3
+ class IndividualDeletePath
4
+ def self.to_oas2(entity_set)
5
+ path_info = new(entity_set)
6
+ {
7
+ 'operationId' => path_info.operation_id,
8
+ 'parameters' => path_info.parameters,
9
+ 'responses' => path_info.responses
10
+ }
11
+ end
12
+
13
+ def initialize(entity_set)
14
+ @entity_set = entity_set
15
+ end
16
+
17
+ def operation_id
18
+ "Delete#{@entity_set.name}"
19
+ end
20
+
21
+ def parameters
22
+ [
23
+ { 'name' => 'id', 'in' => 'path', 'required' => true, 'type' => id_type }
24
+ ]
25
+ end
26
+
27
+ def responses
28
+ {
29
+ '204' => { 'description' => 'No Content' },
30
+ 'default' => { 'description' => 'Unexpected error',
31
+ 'schema' => { '$ref' => '#/definitions/Error' } }
32
+ }
33
+ end
34
+
35
+ private
36
+
37
+ def id_type
38
+ @entity_set.entity_type.integer_property_ref? ? 'integer' : 'string'
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,37 @@
1
+ module OdataDuty
2
+ class OAS2
3
+ class IndividualGetPath < SimpleDelegator
4
+ def to_oas2
5
+ {
6
+ 'operationId' => "GetIndividual#{name}ById",
7
+ 'produces' => ['application/json'],
8
+ 'parameters' => oas2_parameters,
9
+ 'responses' => oas2_responses
10
+ }
11
+ end
12
+
13
+ def oas2_parameters
14
+ [
15
+ {
16
+ 'name' => 'id',
17
+ 'in' => 'path',
18
+ 'required' => true,
19
+ 'type' => entity_type.integer_property_ref? ? 'integer' : 'string'
20
+ }
21
+ ]
22
+ end
23
+
24
+ def oas2_responses
25
+ {
26
+ '200' => {
27
+ 'description' => 'Individual Response',
28
+ 'schema' => {
29
+ '$ref' => "#/definitions/#{entity_type_name}"
30
+ }
31
+ },
32
+ 'default' => DEFAULT_ERROR_RESPONSE
33
+ }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,63 @@
1
+ module OdataDuty
2
+ class OAS2
3
+ class IndividualPatchPath
4
+ def self.to_oas2(entity_set)
5
+ path_info = new(entity_set)
6
+ {
7
+ 'operationId' => path_info.operation_id,
8
+ 'produces' => path_info.produces,
9
+ 'parameters' => path_info.parameters,
10
+ 'responses' => path_info.responses
11
+ }
12
+ end
13
+
14
+ def self.request_body_definition(entity_set)
15
+ writable = entity_set.entity_type.properties.select(&:settable_on_update?)
16
+ definition = { 'type' => 'object',
17
+ 'properties' => writable.to_h { |p| [p.name.to_s, p.to_oas2] } }
18
+ ["#{entity_set.entity_type.name}Update", definition]
19
+ end
20
+
21
+ def initialize(entity_set)
22
+ @entity_set = entity_set
23
+ end
24
+
25
+ def operation_id
26
+ "Update#{@entity_set.name}"
27
+ end
28
+
29
+ def produces
30
+ ['application/json']
31
+ end
32
+
33
+ def parameters
34
+ [
35
+ { 'name' => 'id', 'in' => 'path', 'required' => true, 'type' => id_type },
36
+ { 'name' => 'body', 'in' => 'body', 'required' => true, 'schema' => update_schema }
37
+ ]
38
+ end
39
+
40
+ def responses
41
+ {
42
+ '200' => { 'description' => 'Success', 'schema' => entity_type_schema },
43
+ 'default' => { 'description' => 'Unexpected error',
44
+ 'schema' => { '$ref' => '#/definitions/Error' } }
45
+ }
46
+ end
47
+
48
+ private
49
+
50
+ def id_type
51
+ @entity_set.entity_type.integer_property_ref? ? 'integer' : 'string'
52
+ end
53
+
54
+ def entity_type_schema
55
+ { '$ref' => "#/definitions/#{@entity_set.entity_type.name}" }
56
+ end
57
+
58
+ def update_schema
59
+ { '$ref' => "#/definitions/#{@entity_set.entity_type.name}Update" }
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,113 @@
1
+ require_relative 'context_wrapper'
2
+ require_relative 'schema_builder/endpoint'
3
+
4
+ module OdataDuty
5
+ class OAS2
6
+ def self.build_json(schema, context: nil)
7
+ builder = new(schema, context: context)
8
+ builder.add_error_definition
9
+ builder.add_enum_definitions
10
+ builder.add_complex_definitions
11
+ builder.add_request_body_definitions
12
+ builder.add_collection_paths
13
+ builder.add_individual_paths
14
+ builder.hash
15
+ end
16
+
17
+ attr_reader :hash, :schema, :context, :info, :paths, :definitions
18
+
19
+ def initialize(schema, context:)
20
+ @schema = schema
21
+ @context = context
22
+ @info = {}
23
+ @paths = {}
24
+ @definitions = {}
25
+ @hash = { 'swagger' => '2.0', 'info' => info, 'host' => schema.host,
26
+ 'schemes' => [schema.scheme], 'basePath' => schema.base_path,
27
+ 'paths' => paths, 'definitions' => definitions }
28
+ info['version'] = schema.version if schema.version
29
+ info['title'] = schema.title if schema.title
30
+ end
31
+
32
+ ERROR_PROPERTIES = {
33
+ 'code' => { 'type' => 'string', 'description' => 'A service-defined error code.' },
34
+ 'message' => { 'type' => 'string', 'description' => 'A human-readable message.' },
35
+ 'target' => { 'type' => 'string', 'description' => 'The target of the error.',
36
+ 'x-nullable' => true }
37
+ }.freeze
38
+ def add_error_definition
39
+ definitions['Error'] = {
40
+ 'type' => 'object',
41
+ 'properties' => {
42
+ 'error' => {
43
+ 'type' => 'object',
44
+ 'properties' => ERROR_PROPERTIES
45
+ }
46
+ }
47
+ }
48
+ end
49
+
50
+ def add_enum_definitions
51
+ schema.enum_types.each do |enum_type|
52
+ definitions[enum_type.name] = enum_type.to_oas2
53
+ end
54
+ end
55
+
56
+ def add_complex_definitions
57
+ (schema.complex_types + schema.entity_types).each do |complex_type|
58
+ definitions[complex_type.name] = complex_type.to_oas2
59
+ end
60
+ end
61
+
62
+ def add_request_body_definitions
63
+ schema.collection_entity_sets.select(&:supports_create?).each do |entity_set|
64
+ register_definition(CollectionPostPath.request_body_definition(entity_set))
65
+ end
66
+ schema.individual_entity_sets.select(&:supports_update?).each do |entity_set|
67
+ register_definition(IndividualPatchPath.request_body_definition(entity_set))
68
+ end
69
+ end
70
+
71
+ def add_collection_paths
72
+ schema.collection_entity_sets.each do |entity_set|
73
+ path = { 'get' => CollectionGetPath.new(entity_set, wrap_context(entity_set)).to_oas2 }
74
+ path['post'] = CollectionPostPath.to_oas2(entity_set) if entity_set.supports_create?
75
+ paths["/#{entity_set.url}"] = path
76
+ end
77
+ end
78
+
79
+ def add_individual_paths
80
+ schema.individual_entity_sets.each do |entity_set|
81
+ path = { 'get' => IndividualGetPath.new(entity_set).to_oas2 }
82
+ path['patch'] = IndividualPatchPath.to_oas2(entity_set) if entity_set.supports_update?
83
+ path['delete'] = IndividualDeletePath.to_oas2(entity_set) if entity_set.supports_delete?
84
+ paths["/#{entity_set.url}({id})"] = path
85
+ end
86
+ end
87
+
88
+ DEFAULT_ERROR_RESPONSE = {
89
+ 'description' => 'Unexpected error',
90
+ 'schema' => {
91
+ '$ref' => '#/definitions/Error'
92
+ }
93
+ }.freeze
94
+
95
+ private
96
+
97
+ def register_definition((name, definition))
98
+ definitions[name] = definition
99
+ end
100
+
101
+ def wrap_context(entity_set)
102
+ ContextWrapper.new(@context, base_url: schema.base_url,
103
+ endpoint: SchemaBuilder::Endpoint.new(entity_set),
104
+ query_options: nil)
105
+ end
106
+ end
107
+ end
108
+
109
+ require 'odata_duty/oas2/collection_get_path'
110
+ require 'odata_duty/oas2/collection_post_path'
111
+ require 'odata_duty/oas2/individual_get_path'
112
+ require 'odata_duty/oas2/individual_patch_path'
113
+ require 'odata_duty/oas2/individual_delete_path'
@@ -0,0 +1,163 @@
1
+ require 'parslet'
2
+
3
+ module OdataDuty
4
+ class SearchExpression
5
+ attr_reader :terms, :operator
6
+
7
+ def initialize(terms, operator = :and)
8
+ @terms = terms
9
+ @operator = operator
10
+ end
11
+
12
+ def or?
13
+ @operator == :or
14
+ end
15
+
16
+ def and?
17
+ @operator == :and
18
+ end
19
+
20
+ def self.parse(search_string)
21
+ return new([]) unless search_string.match?(/\S/)
22
+
23
+ parse_tree = build_parse_tree(search_string)
24
+ validate_parse_tree(parse_tree)
25
+ transform(parse_tree)
26
+ end
27
+
28
+ def self.build_parse_tree(search_string)
29
+ parser = ParsletSearchExpressionParser.new
30
+ parser.parse(search_string)
31
+ rescue Parslet::ParseFailed => e
32
+ if search_string.include?('(') || search_string.include?(')')
33
+ raise NoImplementationError, 'Parentheses are not supported'
34
+ end
35
+ if search_string.include?(' AND ') && search_string.include?(' OR ')
36
+ raise NoImplementationError, 'Mixed AND/OR operators are not supported'
37
+ end
38
+
39
+ raise InvalidQueryOptionError, "Invalid search expression: #{e}"
40
+ end
41
+
42
+ def self.validate_parse_tree(parse_tree)
43
+ and_sub_tree = parse_tree[:explicit_and_expr] || parse_tree[:implicit_and_expr]
44
+ return unless and_sub_tree
45
+
46
+ contains_or = and_sub_tree.any? { |t| t.dig(:term, :word) == 'OR' }
47
+ raise NoImplementationError, 'Mixed AND/OR operators are not supported' if contains_or
48
+ end
49
+
50
+ def self.transform(parse_tree)
51
+ transformer = ParsletSearchExpressionTransformer.new
52
+ transformer.apply(parse_tree)
53
+ end
54
+ end
55
+
56
+ class SearchTerm
57
+ attr_reader :value, :negated
58
+
59
+ def initialize(value, negated:)
60
+ @value = value
61
+ @negated = negated
62
+ end
63
+
64
+ def not?
65
+ @negated
66
+ end
67
+
68
+ def to_s
69
+ prefix = ('NOT ' if @negated)
70
+ quoted = @value.include?(' ') ? "\"#{@value}\"" : @value
71
+ "#{prefix}#{quoted}"
72
+ end
73
+ end
74
+
75
+ class ParsletSearchExpressionParser < Parslet::Parser
76
+ # Basic elements
77
+ rule(:space) { match('\s').repeat(1) }
78
+ rule(:space?) { space.maybe }
79
+
80
+ # Word characters - alphanumeric, dots, commas, hyphens, underscores
81
+ rule(:word_char) { match('[a-zA-Z0-9,.\-_]') }
82
+ rule(:word) { word_char.repeat(1).as(:word) }
83
+
84
+ # Quoted phrases
85
+ rule(:quoted_phrase) do
86
+ str('"') >>
87
+ (str('"').absent? >> any).repeat.as(:phrase) >>
88
+ str('"')
89
+ end
90
+
91
+ # NOT operator
92
+ rule(:not_operator) { str('NOT') >> space }
93
+
94
+ # Basic term (word or quoted phrase)
95
+ rule(:basic_term) { quoted_phrase | word }
96
+
97
+ # Term with optional negation
98
+ rule(:term) do
99
+ (not_operator >> basic_term).as(:negated_term) |
100
+ basic_term.as(:term)
101
+ end
102
+
103
+ # Operators
104
+ rule(:and_operator) { space >> str('AND') >> space }
105
+ rule(:or_operator) { space >> str('OR') >> space }
106
+ rule(:implicit_and) { space }
107
+
108
+ # Simplified expression rules
109
+ rule(:or_expression) { term >> (or_operator >> term).repeat(1) }
110
+ rule(:explicit_and_expression) { term >> (and_operator >> term).repeat(1) }
111
+ rule(:implicit_and_expression) { term >> (implicit_and >> term).repeat(1) }
112
+ rule(:single_term_expression) { term }
113
+
114
+ # Main expression - try each pattern
115
+ rule(:expression) do
116
+ or_expression.as(:or_expr) |
117
+ explicit_and_expression.as(:explicit_and_expr) |
118
+ implicit_and_expression.as(:implicit_and_expr) |
119
+ single_term_expression.as(:single_term)
120
+ end
121
+
122
+ # Root rule
123
+ rule(:search_expression) { space? >> expression >> space? }
124
+
125
+ root(:search_expression)
126
+ end
127
+
128
+ class ParsletSearchExpressionTransformer < Parslet::Transform
129
+ rule(word: simple(:word)) { word.to_s }
130
+ rule(phrase: simple(:phrase)) { phrase.to_s }
131
+
132
+ rule(term: simple(:term)) do
133
+ SearchTerm.new(term.to_s, negated: false)
134
+ end
135
+
136
+ rule(negated_term: simple(:term)) do
137
+ SearchTerm.new(term.to_s, negated: true)
138
+ end
139
+
140
+ # Single term expressions
141
+ rule(single_term: simple(:term)) do
142
+ SearchExpression.new([term], :and)
143
+ end
144
+
145
+ # OR expressions
146
+ rule(or_expr: sequence(:terms)) do
147
+ # Multiple terms in OR expression
148
+ SearchExpression.new(terms, :or)
149
+ end
150
+
151
+ # Explicit AND expressions
152
+ rule(explicit_and_expr: sequence(:terms)) do
153
+ # Multiple terms in explicit AND expression
154
+ SearchExpression.new(terms, :and)
155
+ end
156
+
157
+ # Implicit AND expressions
158
+ rule(implicit_and_expr: sequence(:terms)) do
159
+ # Multiple terms in implicit AND expression
160
+ SearchExpression.new(terms, :and)
161
+ end
162
+ end
163
+ end