active_shopify_graphql 0.4.0 → 0.5.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +3 -0
  3. data/README.md +158 -56
  4. data/lib/active_shopify_graphql/configuration.rb +2 -15
  5. data/lib/active_shopify_graphql/connections/connection_loader.rb +141 -0
  6. data/lib/active_shopify_graphql/gid_helper.rb +2 -0
  7. data/lib/active_shopify_graphql/loader.rb +147 -126
  8. data/lib/active_shopify_graphql/loader_context.rb +2 -47
  9. data/lib/active_shopify_graphql/loader_proxy.rb +67 -0
  10. data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +1 -17
  11. data/lib/active_shopify_graphql/loaders/customer_account_api_loader.rb +10 -26
  12. data/lib/active_shopify_graphql/model/associations.rb +94 -0
  13. data/lib/active_shopify_graphql/model/attributes.rb +48 -0
  14. data/lib/active_shopify_graphql/model/connections.rb +174 -0
  15. data/lib/active_shopify_graphql/model/finder_methods.rb +155 -0
  16. data/lib/active_shopify_graphql/model/graphql_type_resolver.rb +89 -0
  17. data/lib/active_shopify_graphql/model/loader_switchable.rb +79 -0
  18. data/lib/active_shopify_graphql/model/metafield_attributes.rb +59 -0
  19. data/lib/active_shopify_graphql/{base.rb → model.rb} +30 -16
  20. data/lib/active_shopify_graphql/model_builder.rb +53 -0
  21. data/lib/active_shopify_graphql/query/node/collection.rb +78 -0
  22. data/lib/active_shopify_graphql/query/node/connection.rb +16 -0
  23. data/lib/active_shopify_graphql/query/node/current_customer.rb +37 -0
  24. data/lib/active_shopify_graphql/query/node/field.rb +23 -0
  25. data/lib/active_shopify_graphql/query/node/fragment.rb +17 -0
  26. data/lib/active_shopify_graphql/query/node/nested_connection.rb +79 -0
  27. data/lib/active_shopify_graphql/query/node/raw.rb +15 -0
  28. data/lib/active_shopify_graphql/query/node/root_connection.rb +76 -0
  29. data/lib/active_shopify_graphql/query/node/single_record.rb +36 -0
  30. data/lib/active_shopify_graphql/query/node/singular.rb +16 -0
  31. data/lib/active_shopify_graphql/query/node.rb +95 -0
  32. data/lib/active_shopify_graphql/query/query_builder.rb +290 -0
  33. data/lib/active_shopify_graphql/query/relation.rb +424 -0
  34. data/lib/active_shopify_graphql/query/scope.rb +219 -0
  35. data/lib/active_shopify_graphql/response/page_info.rb +40 -0
  36. data/lib/active_shopify_graphql/response/paginated_result.rb +114 -0
  37. data/lib/active_shopify_graphql/response/response_mapper.rb +242 -0
  38. data/lib/active_shopify_graphql/search_query/hash_condition_formatter.rb +107 -0
  39. data/lib/active_shopify_graphql/search_query/parameter_binder.rb +68 -0
  40. data/lib/active_shopify_graphql/search_query/value_sanitizer.rb +24 -0
  41. data/lib/active_shopify_graphql/search_query.rb +34 -84
  42. data/lib/active_shopify_graphql/version.rb +1 -1
  43. data/lib/active_shopify_graphql.rb +29 -29
  44. metadata +46 -15
  45. data/lib/active_shopify_graphql/associations.rb +0 -94
  46. data/lib/active_shopify_graphql/attributes.rb +0 -50
  47. data/lib/active_shopify_graphql/connection_loader.rb +0 -96
  48. data/lib/active_shopify_graphql/connections.rb +0 -198
  49. data/lib/active_shopify_graphql/finder_methods.rb +0 -182
  50. data/lib/active_shopify_graphql/fragment_builder.rb +0 -228
  51. data/lib/active_shopify_graphql/graphql_type_resolver.rb +0 -91
  52. data/lib/active_shopify_graphql/includes_scope.rb +0 -48
  53. data/lib/active_shopify_graphql/loader_switchable.rb +0 -180
  54. data/lib/active_shopify_graphql/metafield_attributes.rb +0 -61
  55. data/lib/active_shopify_graphql/query_node.rb +0 -173
  56. data/lib/active_shopify_graphql/query_tree.rb +0 -225
  57. data/lib/active_shopify_graphql/response_mapper.rb +0 -249
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ # Pure factory for building model instances from attribute hashes.
5
+ # Separates data mapping (Loader/ResponseMapper) from object construction.
6
+ #
7
+ # Responsibilities:
8
+ # - Build single model instances from attributes
9
+ # - Apply connection caches to instances
10
+ # - Handle batch building with connection caching
11
+ #
12
+ # @example Single instance
13
+ # attributes = loader.load_attributes(gid)
14
+ # customer = ModelBuilder.build(Customer, attributes)
15
+ #
16
+ # @example Batch building
17
+ # attributes_list = loader.load_paginated_attributes(...)
18
+ # customers = ModelBuilder.build_many(Customer, attributes_list)
19
+ class ModelBuilder
20
+ class << self
21
+ # Build a single model instance from attributes
22
+ # @param model_class [Class] The model class to instantiate
23
+ # @param attributes [Hash] Attribute hash (may contain :_connection_cache)
24
+ # @return [Object] The instantiated model with cached connections
25
+ def build(model_class, attributes)
26
+ return nil if attributes.nil?
27
+
28
+ instance = model_class.new(attributes)
29
+ apply_connection_cache(instance, attributes)
30
+ instance
31
+ end
32
+
33
+ # Build multiple model instances from an array of attribute hashes
34
+ # @param model_class [Class] The model class to instantiate
35
+ # @param attributes_array [Array<Hash>] Array of attribute hashes
36
+ # @return [Array<Object>] Array of instantiated models
37
+ def build_many(model_class, attributes_array)
38
+ return [] if attributes_array.nil? || attributes_array.empty?
39
+
40
+ attributes_array.filter_map { |attrs| build(model_class, attrs) }
41
+ end
42
+
43
+ # Apply connection cache from attributes to an already-instantiated model
44
+ # @param instance [Object] The model instance
45
+ # @param attributes [Hash] Attribute hash that may contain :_connection_cache
46
+ def apply_connection_cache(instance, attributes)
47
+ return unless attributes.is_a?(Hash) && attributes.key?(:_connection_cache)
48
+
49
+ instance.instance_variable_set(:@_connection_cache, attributes[:_connection_cache])
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders a collection query with optional pagination.
7
+ # Example without pagination:
8
+ # query getCustomers {
9
+ # customers(first: 10) {
10
+ # nodes {
11
+ # ...CustomerFragment
12
+ # }
13
+ # }
14
+ # }
15
+ #
16
+ # Example with pagination:
17
+ # query getCustomers {
18
+ # customers(first: 10, after: "cursor") {
19
+ # pageInfo {
20
+ # hasNextPage
21
+ # hasPreviousPage
22
+ # startCursor
23
+ # endCursor
24
+ # }
25
+ # nodes {
26
+ # ...CustomerFragment
27
+ # }
28
+ # }
29
+ # }
30
+ class Collection < Node
31
+ attr_reader :model_type, :query_name, :fragment_name, :variables, :fragments, :include_page_info
32
+
33
+ def initialize(model_type:, query_name:, fragment_name:, variables: {}, fragments: [], include_page_info: false)
34
+ @model_type = model_type
35
+ @query_name = query_name
36
+ @fragment_name = fragment_name
37
+ @variables = variables
38
+ @fragments = fragments
39
+ @include_page_info = include_page_info
40
+ super(name: query_name)
41
+ end
42
+
43
+ def to_s
44
+ parts = [fragments_string, "query get#{model_type.pluralize} { #{query_name}#{field_signature} {"]
45
+ parts << PAGE_INFO_FIELDS if @include_page_info
46
+ parts << "nodes { ...#{fragment_name} }"
47
+ parts << "} }"
48
+ parts.join(' ')
49
+ end
50
+
51
+ private
52
+
53
+ def fragments_string
54
+ @fragments.map(&:to_s).join(' ')
55
+ end
56
+
57
+ def field_signature
58
+ params = build_field_parameters(@variables.compact)
59
+ params.empty? ? "" : "(#{params.join(', ')})"
60
+ end
61
+
62
+ def build_field_parameters(variables)
63
+ variables.map do |key, value|
64
+ "#{key.to_s.camelize(:lower)}: #{format_inline_value(key, value)}"
65
+ end
66
+ end
67
+
68
+ def format_inline_value(key, value)
69
+ case value
70
+ when Integer, TrueClass, FalseClass then value.to_s
71
+ when String then STRING_KEYS_NEEDING_QUOTES.include?(key.to_sym) ? "\"#{value}\"" : value
72
+ else value.to_s
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders connection fields using the nodes pattern.
7
+ # Example: `orders(first: 10) { nodes { id createdAt } }`
8
+ class Connection < Node
9
+ def to_s
10
+ nested_fields = render_children
11
+ "#{field_name_with_alias}#{format_arguments} { nodes { #{nested_fields.join(' ')} } }"
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders a query that doesn't require an ID parameter.
7
+ # Used for Customer Account API's current customer query.
8
+ # Example:
9
+ # query getCurrentCustomer {
10
+ # customer {
11
+ # ...CustomerFragment
12
+ # }
13
+ # }
14
+ class CurrentCustomer < Node
15
+ attr_reader :model_type, :query_name, :fragment_name, :fragments
16
+
17
+ def initialize(model_type:, query_name:, fragment_name:, fragments: [])
18
+ @model_type = model_type
19
+ @query_name = query_name
20
+ @fragment_name = fragment_name
21
+ @fragments = fragments
22
+ super(name: query_name)
23
+ end
24
+
25
+ def to_s
26
+ "#{fragments_string} query getCurrent#{model_type} { #{query_name} { ...#{fragment_name} } }"
27
+ end
28
+
29
+ private
30
+
31
+ def fragments_string
32
+ @fragments.map(&:to_s).join(' ')
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders simple fields and nested fields with children.
7
+ # Examples:
8
+ # - Simple: `id`
9
+ # - With alias: `customerId: id`
10
+ # - Nested: `defaultAddress { city country }`
11
+ class Field < Node
12
+ def to_s
13
+ full_name = "#{field_name_with_alias}#{format_arguments}"
14
+
15
+ return full_name unless has_children?
16
+
17
+ nested_fields = render_children
18
+ "#{full_name} { #{nested_fields.join(' ')} }"
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders GraphQL fragment definitions.
7
+ # Example: `fragment CustomerFragment on Customer { id displayName email }`
8
+ class Fragment < Node
9
+ def to_s
10
+ type_name = @arguments[:on]
11
+ fields = render_children
12
+ "fragment #{@name} on #{type_name} { #{fields.join(' ')} }"
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders a nested connection query (loaded from a parent record).
7
+ # Example:
8
+ # query($id: ID!) {
9
+ # customer(id: $id) {
10
+ # orders(first: 10) {
11
+ # pageInfo {
12
+ # hasNextPage
13
+ # hasPreviousPage
14
+ # startCursor
15
+ # endCursor
16
+ # }
17
+ # nodes {
18
+ # ...OrderFragment
19
+ # }
20
+ # }
21
+ # }
22
+ # }
23
+ class NestedConnection < Node
24
+ attr_reader :query_name, :fragment_name, :variables, :parent_query, :fragments, :singular
25
+
26
+ def initialize(query_name:, fragment_name:, parent_query:, variables: {}, fragments: [], singular: false)
27
+ @query_name = query_name
28
+ @fragment_name = fragment_name
29
+ @variables = variables
30
+ @parent_query = parent_query
31
+ @fragments = fragments
32
+ @singular = singular
33
+ super(name: query_name)
34
+ end
35
+
36
+ def to_s(*)
37
+ if @singular
38
+ render_singular_connection
39
+ else
40
+ render_nodes_connection
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def render_singular_connection
47
+ "#{fragments_string} query($id: ID!) { #{parent_query} { #{query_name}#{field_signature} { ...#{fragment_name} } } }"
48
+ end
49
+
50
+ def render_nodes_connection
51
+ "#{fragments_string} query($id: ID!) { #{parent_query} { #{query_name}#{field_signature} { #{PAGE_INFO_FIELDS} nodes { ...#{fragment_name} } } } }"
52
+ end
53
+
54
+ def fragments_string
55
+ @fragments.map(&:to_s).join(' ')
56
+ end
57
+
58
+ def field_signature
59
+ params = build_field_parameters(@variables.compact)
60
+ params.empty? ? "" : "(#{params.join(', ')})"
61
+ end
62
+
63
+ def build_field_parameters(variables)
64
+ variables.map do |key, value|
65
+ "#{key.to_s.camelize(:lower)}: #{format_inline_value(key, value)}"
66
+ end
67
+ end
68
+
69
+ def format_inline_value(key, value)
70
+ case value
71
+ when Integer, TrueClass, FalseClass then value.to_s
72
+ when String then STRING_KEYS_NEEDING_QUOTES.include?(key.to_sym) ? "\"#{value}\"" : value
73
+ else value.to_s
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders raw GraphQL strings verbatim.
7
+ # Used for custom GraphQL snippets that don't fit other node types.
8
+ class Raw < Node
9
+ def to_s(*)
10
+ @arguments[:raw_graphql]
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders a root-level connection query (no parent record).
7
+ # Example:
8
+ # query {
9
+ # customers(first: 10) {
10
+ # pageInfo {
11
+ # hasNextPage
12
+ # hasPreviousPage
13
+ # startCursor
14
+ # endCursor
15
+ # }
16
+ # nodes {
17
+ # ...CustomerFragment
18
+ # }
19
+ # }
20
+ # }
21
+ class RootConnection < Node
22
+ attr_reader :query_name, :fragment_name, :variables, :fragments, :singular
23
+
24
+ def initialize(query_name:, fragment_name:, variables: {}, fragments: [], singular: false)
25
+ @query_name = query_name
26
+ @fragment_name = fragment_name
27
+ @variables = variables
28
+ @fragments = fragments
29
+ @singular = singular
30
+ super(name: query_name)
31
+ end
32
+
33
+ def to_s(*)
34
+ if @singular
35
+ render_singular_connection
36
+ else
37
+ render_nodes_connection
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def render_singular_connection
44
+ "#{fragments_string} query { #{query_name}#{field_signature} { ...#{fragment_name} } }"
45
+ end
46
+
47
+ def render_nodes_connection
48
+ "#{fragments_string} query { #{query_name}#{field_signature} { #{PAGE_INFO_FIELDS} nodes { ...#{fragment_name} } } }"
49
+ end
50
+
51
+ def fragments_string
52
+ @fragments.map(&:to_s).join(' ')
53
+ end
54
+
55
+ def field_signature
56
+ params = build_field_parameters(@variables.compact)
57
+ params.empty? ? "" : "(#{params.join(', ')})"
58
+ end
59
+
60
+ def build_field_parameters(variables)
61
+ variables.map do |key, value|
62
+ "#{key.to_s.camelize(:lower)}: #{format_inline_value(key, value)}"
63
+ end
64
+ end
65
+
66
+ def format_inline_value(key, value)
67
+ case value
68
+ when Integer, TrueClass, FalseClass then value.to_s
69
+ when String then STRING_KEYS_NEEDING_QUOTES.include?(key.to_sym) ? "\"#{value}\"" : value
70
+ else value.to_s
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders a single record query by ID.
7
+ # Example:
8
+ # query getCustomer($id: ID!) {
9
+ # customer(id: $id) {
10
+ # ...CustomerFragment
11
+ # }
12
+ # }
13
+ class SingleRecord < Node
14
+ attr_reader :model_type, :query_name, :fragment_name, :fragments
15
+
16
+ def initialize(model_type:, query_name:, fragment_name:, fragments: [])
17
+ @model_type = model_type
18
+ @query_name = query_name
19
+ @fragment_name = fragment_name
20
+ @fragments = fragments
21
+ super(name: query_name)
22
+ end
23
+
24
+ def to_s
25
+ "#{fragments_string} query get#{model_type}($id: ID!) { #{query_name}(id: $id) { ...#{fragment_name} } }"
26
+ end
27
+
28
+ private
29
+
30
+ def fragments_string
31
+ @fragments.map(&:to_s).join(' ')
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ class Node
6
+ # Renders singular association fields (has_one relationships).
7
+ # Example: `defaultAddress { city country zip }`
8
+ class Singular < Node
9
+ def to_s
10
+ nested_fields = render_children
11
+ "#{field_name_with_alias}#{format_arguments} { #{nested_fields.join(' ')} }"
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ # Abstract base class for all GraphQL query nodes.
6
+ # Provides shared attributes and helper methods for node rendering.
7
+ #
8
+ # Subclasses:
9
+ # - Node::Field - Simple and nested fields
10
+ # - Node::Singular - Singular associations (has_one)
11
+ # - Node::Connection - Collection associations using nodes pattern
12
+ # - Node::Fragment - Fragment definitions
13
+ # - Node::Raw - Raw GraphQL strings
14
+ # - Node::SingleRecord - Single record queries by ID
15
+ # - Node::CurrentCustomer - ID-less queries (Customer Account API)
16
+ # - Node::Collection - Collection queries with optional pagination
17
+ # - Node::NestedConnection - Nested connection queries
18
+ # - Node::RootConnection - Root-level connection queries
19
+ class Node
20
+ # Shared constants for query formatting
21
+ PAGE_INFO_FIELDS = "pageInfo { hasNextPage hasPreviousPage startCursor endCursor }"
22
+ STRING_KEYS_NEEDING_QUOTES = %i[query after before].freeze
23
+ attr_reader :name, :alias_name, :arguments, :children
24
+
25
+ # @param name [String] The field name (e.g., 'id', 'displayName', 'orders')
26
+ # @param alias_name [String] Optional field alias (e.g., 'myAlias' for 'myAlias: fieldName')
27
+ # @param arguments [Hash] Field arguments (e.g., { first: 10, sortKey: 'CREATED_AT' })
28
+ # @param children [Array<Query::Node>] Child nodes for nested structures
29
+ def initialize(name:, alias_name: nil, arguments: {}, children: [])
30
+ @name = name
31
+ @alias_name = alias_name
32
+ @arguments = arguments
33
+ @children = children
34
+ end
35
+
36
+ # Add a child node
37
+ def add_child(node)
38
+ @children << node
39
+ node
40
+ end
41
+
42
+ # Check if node has children
43
+ def has_children?
44
+ @children.any?
45
+ end
46
+
47
+ # Convert node to GraphQL string - must be implemented by subclasses
48
+ def to_s
49
+ raise NotImplementedError, "#{self.class} must implement #to_s"
50
+ end
51
+
52
+ private
53
+
54
+ def field_name_with_alias
55
+ @alias_name ? "#{@alias_name}: #{@name}" : @name
56
+ end
57
+
58
+ def format_arguments
59
+ return "" if @arguments.empty?
60
+
61
+ # Filter out internal arguments (like :on for fragments)
62
+ graphql_args = @arguments.except(:on)
63
+ return "" if graphql_args.empty?
64
+
65
+ args = graphql_args.map do |key, value|
66
+ graphql_key = key.to_s.camelize(:lower)
67
+ formatted_value = format_argument_value(key, value)
68
+ "#{graphql_key}: #{formatted_value}"
69
+ end
70
+
71
+ "(#{args.join(', ')})"
72
+ end
73
+
74
+ def format_argument_value(key, value)
75
+ case value
76
+ when String
77
+ # Keys that need quoted string values
78
+ if %i[namespace key query].include?(key.to_sym)
79
+ "\"#{value}\""
80
+ else
81
+ value
82
+ end
83
+ when Symbol
84
+ value.to_s
85
+ else
86
+ value
87
+ end
88
+ end
89
+
90
+ def render_children
91
+ @children.map(&:to_s)
92
+ end
93
+ end
94
+ end
95
+ end