freight_kit 0.1.21 → 0.1.22

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -3
  3. data/Rakefile +1 -3
  4. data/VERSION +1 -1
  5. data/freight_kit-0.1.21.gem +0 -0
  6. data/freight_kit.gemspec +8 -19
  7. data/lib/freight_kit/address_normalizer.rb +1 -3
  8. data/lib/freight_kit/api_clients/soap_client.rb +30 -5
  9. data/lib/freight_kit/carrier.rb +6 -7
  10. data/lib/freight_kit/carriers/abf_freight_system.rb +9 -26
  11. data/lib/freight_kit/carriers/aloha_freight_forwarders.rb +1 -5
  12. data/lib/freight_kit/carriers/amado_trucking.rb +1 -5
  13. data/lib/freight_kit/carriers/clear_lane_freight_systems.rb +6 -15
  14. data/lib/freight_kit/carriers/cpl_express.rb +1 -5
  15. data/lib/freight_kit/carriers/cross_country_freight_solutions.rb +6 -17
  16. data/lib/freight_kit/carriers/dependable_highway_express.rb +6 -18
  17. data/lib/freight_kit/carriers/forward_air.rb +4 -12
  18. data/lib/freight_kit/carriers/on_trac.rb +7 -25
  19. data/lib/freight_kit/carriers/peninsula_truck_lines.rb +4 -20
  20. data/lib/freight_kit/carriers/roadrunner_transportation_services.rb +2 -8
  21. data/lib/freight_kit/carriers/saia.rb +4 -19
  22. data/lib/freight_kit/carriers/southeastern_freight_lines.rb +4 -13
  23. data/lib/freight_kit/carriers/t_force_worldwide.rb +7 -19
  24. data/lib/freight_kit/carriers/total_quality_logistics.rb +2 -1
  25. data/lib/freight_kit/carriers/western_regional_delivery_service.rb +5 -11
  26. data/lib/freight_kit/carriers/xpo_logistics.rb +5 -17
  27. data/lib/freight_kit/errors/rate_limit_error.rb +12 -0
  28. data/lib/freight_kit/errors/unserviceable_accessorials_error.rb +1 -3
  29. data/lib/freight_kit/helpers/pickupable.rb +1 -10
  30. data/lib/freight_kit/http_client/httpi_adapter.rb +37 -0
  31. data/lib/freight_kit/http_client/safe_retries.rb +17 -1
  32. data/lib/freight_kit/http_client.rb +13 -9
  33. data/lib/freight_kit/models/credential.rb +2 -15
  34. data/lib/freight_kit/models/shipment.rb +1 -8
  35. data/lib/freight_kit/package.rb +3 -13
  36. data/lib/freight_kit/packaging.rb +1 -16
  37. data/lib/freight_kit/platforms/carrier_logistics.rb +5 -10
  38. data/lib/freight_kit/platforms/dsdc.rb +82 -0
  39. data/lib/freight_kit/platforms/next_tms/graphql_client/mutation/quote_create.rb +32 -0
  40. data/lib/freight_kit/platforms/next_tms/graphql_client/mutation/shipment_create.rb +29 -0
  41. data/lib/freight_kit/platforms/next_tms/graphql_client/query/broker_carrier.rb +37 -0
  42. data/lib/freight_kit/platforms/next_tms/graphql_client/query/broker_carriers.rb +45 -0
  43. data/lib/freight_kit/platforms/next_tms/graphql_client/query/broker_customer.rb +54 -0
  44. data/lib/freight_kit/platforms/next_tms/graphql_client/query/broker_customers.rb +62 -0
  45. data/lib/freight_kit/platforms/next_tms/graphql_client/query/carrier.rb +22 -0
  46. data/lib/freight_kit/platforms/next_tms/graphql_client/query/carriers.rb +30 -0
  47. data/lib/freight_kit/platforms/next_tms/graphql_client/query/customer.rb +20 -0
  48. data/lib/freight_kit/platforms/next_tms/graphql_client/query/customers.rb +28 -0
  49. data/lib/freight_kit/platforms/next_tms/graphql_client/query/invoice.rb +31 -0
  50. data/lib/freight_kit/platforms/next_tms/graphql_client/query/invoices.rb +39 -0
  51. data/lib/freight_kit/platforms/next_tms/graphql_client/query/shipment.rb +102 -0
  52. data/lib/freight_kit/platforms/next_tms/graphql_client/query/shipment_documents.rb +29 -0
  53. data/lib/freight_kit/platforms/next_tms/graphql_client/query/shipments.rb +110 -0
  54. data/lib/freight_kit/platforms/next_tms/graphql_client.rb +22 -0
  55. data/lib/freight_kit/platforms/next_tms/orm/address.rb +22 -0
  56. data/lib/freight_kit/platforms/next_tms/orm/base.rb +120 -0
  57. data/lib/freight_kit/platforms/next_tms/orm/broker_carrier.rb +18 -0
  58. data/lib/freight_kit/platforms/next_tms/orm/broker_customer.rb +22 -0
  59. data/lib/freight_kit/platforms/next_tms/orm/carrier.rb +14 -0
  60. data/lib/freight_kit/platforms/next_tms/orm/collection.rb +335 -0
  61. data/lib/freight_kit/platforms/next_tms/orm/customer.rb +12 -0
  62. data/lib/freight_kit/platforms/next_tms/orm/invoice.rb +21 -0
  63. data/lib/freight_kit/platforms/next_tms/orm/record_not_found_error.rb +9 -0
  64. data/lib/freight_kit/platforms/next_tms/orm/shipment.rb +50 -0
  65. data/lib/freight_kit/platforms/next_tms/orm.rb +38 -0
  66. data/lib/freight_kit/platforms/next_tms.rb +414 -55
  67. data/lib/freight_kit/platforms/the_great_information_factory.rb +4 -12
  68. data/lib/freight_kit.rb +19 -0
  69. data/schemas/next_tms.json +110710 -0
  70. metadata +83 -5
@@ -0,0 +1,110 @@
1
+ module FreightKit
2
+ module Platforms
3
+ class NextTMS
4
+ class GraphQLClient
5
+ module Query
6
+ class Shipments
7
+ DETAILS = Client.parse(<<~GRAPHQL)
8
+ query($filter: ShipmentFilter, $page: Int, $pageSize: Int, $sort: [String!]) {
9
+ shipments(filter: $filter, page: $page, pageSize: $pageSize, sort: $sort) {
10
+ items {
11
+ bol
12
+ bookedAt
13
+ brokerCarrier {
14
+ account
15
+ carrier {
16
+ id
17
+ mcNumber
18
+ name
19
+ scac
20
+ }
21
+ id
22
+ }
23
+ brokerCustomer {
24
+ customer {
25
+ id
26
+ name
27
+ }
28
+ id
29
+ }
30
+ canceledAt
31
+ createdAt
32
+ customerReference
33
+ deliveredAt
34
+ deliveryAppointmentAt
35
+ deliveryAppointmentOn
36
+ deliveryEtaOn
37
+ departedOriginTerminalAt
38
+ dispatchedAt
39
+ events {
40
+ city
41
+ countryAlpha2
42
+ id
43
+ name
44
+ occurredAt
45
+ regionIso31662
46
+ typeCode
47
+ zipCode
48
+ }
49
+ id
50
+ loadId
51
+ lostOn
52
+ outForDeliveryAt
53
+ pendingAppointmentSince
54
+ pickedUpAt
55
+ pickupDriverAssignedAt
56
+ pickupOn
57
+ pro
58
+ receiverAddress {
59
+ city
60
+ country {
61
+ alpha2Code
62
+ }
63
+ id
64
+ region {
65
+ abbreviation
66
+ }
67
+ street
68
+ street2
69
+ timeZoneName
70
+ zipCode
71
+ }
72
+ receiverName
73
+ shipperAddress {
74
+ city
75
+ country {
76
+ alpha2Code
77
+ }
78
+ id
79
+ region {
80
+ abbreviation
81
+ }
82
+ street
83
+ street2
84
+ timeZoneName
85
+ zipCode
86
+ }
87
+ shipperBol
88
+ shipperName
89
+ shipperReference
90
+ status
91
+ totalChargesCents
92
+ totalChargesCurrency
93
+ truckload
94
+ updatedAt
95
+ }
96
+ page
97
+ pageSize
98
+ pages
99
+ totalItems
100
+ updatedAt
101
+ version
102
+ }
103
+ }
104
+ GRAPHQL
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,22 @@
1
+ module FreightKit
2
+ module Platforms
3
+ class NextTMS
4
+ # Parses each `Query::*::DETAILS` document against the Next TMS schema when it loads, so a wrong field or argument
5
+ # fails at boot rather than at request time.
6
+ class GraphQLClient
7
+ # Sends documents to the tenant in `context[:platform]`, so one parsed document serves every tenant.
8
+ class HTTPAdapter
9
+ def execute(document:, context: {}, operation_name: nil, variables: {})
10
+ context.fetch(:platform).execute(document.to_query_string, operation_name:, variables:)
11
+ end
12
+ end
13
+
14
+ # Introspection of the GraphQL API, which every tenant shares
15
+ SCHEMA_PATH = File.expand_path('../../../../schemas/next_tms.json', __dir__).freeze
16
+
17
+ Schema = GraphQL::Client.load_schema(JSON.parse(File.read(SCHEMA_PATH)))
18
+ Client = GraphQL::Client.new(schema: Schema, execute: HTTPAdapter.new)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ module FreightKit
2
+ module Platforms
3
+ class NextTMS
4
+ module ORM
5
+ # Embedded in other records, so it has no query of its own and is always loaded.
6
+ class Address < Base
7
+ def city = @city ||= attribute(:city)
8
+ def country = @country ||= attribute(:country)&.dig(:alpha2_code)&.then { |code| ActiveUtils::Country.find(code) }
9
+ def loaded? = true
10
+ def region = @region ||= attribute(:region)&.dig(:abbreviation)
11
+ def street = @street ||= attribute(:street)
12
+ def street2 = @street2 ||= attribute(:street2)
13
+ def time_zone_name = @time_zone_name ||= attribute(:time_zone_name)
14
+
15
+ def to_location = Location.new(address1: street, address2: street2, city:, country:, postal_code: zip_code, province: region)
16
+
17
+ def zip_code = @zip_code ||= attribute(:zip_code)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,120 @@
1
+ module FreightKit
2
+ module Platforms
3
+ class NextTMS
4
+ module ORM
5
+ # A Next TMS record, loaded lazily from the tenant's GraphQL API with the {GraphQLClient::Query} of the same name.
6
+ # Records from a collection are loaded. Records from an association hold only the fields that its query selects
7
+ # for it, and load the rest when one is read.
8
+ class Base
9
+ class << self
10
+ attr_accessor :orm
11
+
12
+ delegate :count,
13
+ :find,
14
+ :find_by,
15
+ :find_by!,
16
+ :first,
17
+ :first!,
18
+ :ids,
19
+ :last,
20
+ :limit,
21
+ :none,
22
+ :not,
23
+ :offset,
24
+ :order,
25
+ :pluck,
26
+ :where,
27
+ to: :all
28
+
29
+ def all = Collection.new(self)
30
+ def association_type_mapping = {}
31
+
32
+ def build_association(name, data)
33
+ return if data&.dig(:id).blank?
34
+
35
+ class_name = association_type_mapping[name.to_sym] || name.to_s.classify
36
+ orm.const_get(class_name).new(data[:id], **data.except(:id))
37
+ end
38
+
39
+ def build_associations(name, items) = Array(items).filter_map { |data| build_association(name.to_s.singularize, data) }
40
+
41
+ # @return [Symbol] The collection query, e.g. `:broker_carriers` for {GraphQLClient::Query::BrokerCarriers}.
42
+ def collection_operation = graphql_name.pluralize.underscore.to_sym
43
+
44
+ # @return [Symbol] The singular query, e.g. `:broker_carrier` for {GraphQLClient::Query::BrokerCarrier}.
45
+ def element_operation = graphql_name.underscore.to_sym
46
+
47
+ def graphql_name = superclass == Base ? name.demodulize : superclass.graphql_name
48
+
49
+ def platform
50
+ raise ArgumentError, "#{name} is not bound to a tenant, use e.g. FreightKit::Carriers::JFJTransportation::ORM" if orm.nil?
51
+
52
+ orm.platform
53
+ end
54
+ end
55
+
56
+ # Instance variables that {#reload} keeps, all others memoize attributes
57
+ STATE = %i[@attributes @id @loaded].freeze
58
+
59
+ attr_reader :id
60
+
61
+ delegate :orm, :platform, to: :class
62
+
63
+ def initialize(id, created_at: nil, loaded: false, updated_at: nil, **attributes)
64
+ raise ArgumentError, 'id cannot be blank' if id.blank?
65
+
66
+ @attributes = { created_at:, updated_at: }.compact.merge(attributes)
67
+ # The API returns IDs without leading zeros, so `'01'` and `'1'` are the same record
68
+ @id = id.is_a?(String) && id.match?(/\A\d+\z/) ? id.to_i.to_s : id
69
+ @loaded = loaded
70
+ end
71
+
72
+ def ==(other) = other.instance_of?(self.class) && other.id.to_s == id.to_s
73
+ alias eql? ==
74
+
75
+ def attribute(key)
76
+ reload unless @attributes.key?(key) || loaded?
77
+
78
+ @attributes[key]
79
+ end
80
+
81
+ def attributes
82
+ reload unless loaded?
83
+
84
+ @attributes
85
+ end
86
+
87
+ def hash = [self.class, id.to_s].hash
88
+ def inspect = "#<#{self.class.name} id=#{id}>"
89
+ def loaded? = @loaded
90
+
91
+ # Loads every attribute, from {FreightKit.cache} when present.
92
+ def reload = load(cache: true)
93
+
94
+ # Loads every attribute from the API.
95
+ def reload! = load(cache: false)
96
+
97
+ private
98
+
99
+ def date(key) = attribute(key)&.then { |value| Date.iso8601(value) }
100
+ def fetch_association(name) = self.class.build_association(name, attribute(name))
101
+ def fetch_associations(name) = self.class.build_associations(name, attribute(name))
102
+
103
+ def load(cache:)
104
+ operation = self.class.element_operation
105
+ data = platform.query(operation, cache:, id:)[operation]
106
+ raise RecordNotFoundError, "Couldn't find #{self.class.name} with id #{id}" if data.nil?
107
+
108
+ (instance_variables - STATE).each { |variable| remove_instance_variable(variable) }
109
+ @attributes = data.except(:id)
110
+ @loaded = true
111
+
112
+ self
113
+ end
114
+
115
+ def time(key) = attribute(key)&.then { |value| Time.iso8601(value) }
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,18 @@
1
+ module FreightKit
2
+ module Platforms
3
+ class NextTMS
4
+ module ORM
5
+ class BrokerCarrier < Base
6
+ def account = @account ||= attribute(:account)
7
+ def account_desc = @account_desc ||= attribute(:account_desc)
8
+ def broker_customer = @broker_customer ||= fetch_association(:broker_customer)
9
+ def carrier = @carrier ||= fetch_association(:carrier)
10
+ def create_invoice_on_pickup? = attribute(:create_invoice_on_pickup)
11
+ def days_to_pay_after_invoice_date = @days_to_pay_after_invoice_date ||= attribute(:days_to_pay_after_invoice_date)
12
+ def shipments = @shipments ||= orm::Shipment.where(broker_carrier: self)
13
+ def shipments_count = @shipments_count ||= attribute(:shipments_count)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,22 @@
1
+ module FreightKit
2
+ module Platforms
3
+ class NextTMS
4
+ module ORM
5
+ class BrokerCustomer < Base
6
+ class << self
7
+ def association_type_mapping = super.merge(office_address: 'Address', primary_pickup_address: 'Address')
8
+ end
9
+
10
+ def accounting_customer_id = @accounting_customer_id ||= attribute(:accounting_customer_id)
11
+ def broker_carriers = @broker_carriers ||= orm::BrokerCarrier.where(broker_customer: self)
12
+ def can_quote? = attribute(:can_quote)
13
+ def customer = @customer ||= fetch_association(:customer)
14
+ def office_address = @office_address ||= fetch_association(:office_address)
15
+ def primary_pickup_address = @primary_pickup_address ||= fetch_association(:primary_pickup_address)
16
+ def shipments = @shipments ||= orm::Shipment.where(broker_customer: self)
17
+ def valid_freight_classes = @valid_freight_classes ||= attribute(:valid_freight_classes)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,14 @@
1
+ module FreightKit
2
+ module Platforms
3
+ class NextTMS
4
+ module ORM
5
+ class Carrier < Base
6
+ def broker_carriers = @broker_carriers ||= orm::BrokerCarrier.where(carrier: self)
7
+ def mc_number = @mc_number ||= attribute(:mc_number)
8
+ def name = @name ||= attribute(:name)
9
+ def scac = @scac ||= attribute(:scac)
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,335 @@
1
+ module FreightKit
2
+ module Platforms
3
+ class NextTMS
4
+ module ORM
5
+ # A lazy, chainable query of a collection field, e.g. `shipments(filter:, page:, pageSize:, sort:)`.
6
+ #
7
+ # Conditions become ransack predicates (`where(pro: '123')` => `proEq`), so only the filter fields the tenant
8
+ # generates from the model's ransackable attributes work. Without {#limit}, {#to_a} fetches every page.
9
+ class Collection
10
+ include Enumerable
11
+
12
+ # Pairs of opposite predicates, e.g. `delivered_at_gteq` is the opposite of `delivered_at_lt`
13
+ NEGATIONS = {
14
+ 'cont' => 'not_cont', 'end' => 'not_end', 'eq' => 'not_eq', 'gt' => 'lteq', 'gteq' => 'lt', 'in' => 'not_in',
15
+ 'start' => 'not_start'
16
+ }.freeze
17
+ PAGE_SIZE = 100 # the API's maximum
18
+ PREDICATE = /_(cont|end|eq|gt|gteq|in|lt|lteq|not_cont|not_end|not_eq|not_in|not_start|null|start)\z/
19
+
20
+ attr_reader :filter, :orm_class, :page, :page_size, :sort, :updated_at, :version
21
+
22
+ attr_writer :orm_objects
23
+
24
+ delegate :[], :each, :each_with_index, :map, to: :to_a
25
+
26
+ def initialize(orm_class, **options)
27
+ raise ArgumentError, 'orm_class must be descended from FreightKit::Platforms::NextTMS::ORM::Base' unless orm_class < Base
28
+
29
+ @filter = options[:filter] || {}
30
+ @none = options[:none] || false
31
+ @offset = options[:offset]
32
+ @orm_class = orm_class
33
+ @page = options[:page]
34
+ @page_size = options[:page_size]
35
+ @sort = Array(options[:sort]).compact.presence
36
+ @total_items = options[:total_items]
37
+ @updated_at = options[:updated_at]
38
+ end
39
+
40
+ # Arguments and blocks need the records, the bare forms only the total
41
+ def any?(*args, &) = args.empty? && !block_given? ? total_items.positive? : super
42
+ def blank? = !any?
43
+ def count(*args, &) = args.empty? && !block_given? ? total_items : super
44
+ def empty? = total_items.zero?
45
+
46
+ # The singular query cannot apply the collection's filter, so a filtered collection filters by ID too, and a
47
+ # limited one searches its page
48
+ def find(id)
49
+ record = if @none then nil
50
+ elsif page_size.present? then to_a.find { |item| integer_id(item.id).to_s == integer_id(id).to_s }
51
+ elsif filter.blank? then orm_class.new(id).reload
52
+ else where(id:).limit(1).first
53
+ end
54
+
55
+ record || raise(RecordNotFoundError, "Couldn't find #{orm_class} with id #{id}")
56
+ end
57
+
58
+ def find_by(**conditions)
59
+ find_by!(**conditions)
60
+ rescue RecordNotFoundError
61
+ nil
62
+ end
63
+
64
+ def find_by!(**conditions)
65
+ return find(conditions[:id]) if conditions.keys == [:id]
66
+
67
+ where(**conditions).limit(1).first || raise(RecordNotFoundError, "Couldn't find #{orm_class} with #{conditions}")
68
+ end
69
+
70
+ def first = page_size.present? ? orm_objects.first : limit(1).orm_objects.first
71
+ def first! = first || raise(RecordNotFoundError, "Couldn't find #{orm_class}")
72
+
73
+ def ids = pluck(:id)
74
+ def inspect = "#<#{self.class.name} orm_class=#{orm_class}>"
75
+
76
+ def last
77
+ return orm_objects.last if page_size.present?
78
+
79
+ offset(total_items - 1).limit(1).orm_objects.first unless empty?
80
+ end
81
+
82
+ def limit(page_size)
83
+ raise ArgumentError, 'page_size must be 1..100' unless page_size.is_a?(Integer) && page_size.between?(1, PAGE_SIZE)
84
+
85
+ new_instance(page: @offset ? offset_page(@offset, page_size) : page || 1, page_size:)
86
+ end
87
+
88
+ def new_instance(**options)
89
+ self.class.new(
90
+ orm_class,
91
+ filter: options.fetch(:filter, filter),
92
+ none: options.fetch(:none, @none),
93
+ offset: options.fetch(:offset, @offset),
94
+ page: options.fetch(:page, page),
95
+ page_size: options.fetch(:page_size, page_size),
96
+ sort: options.fetch(:sort, sort),
97
+ total_items: options[:total_items],
98
+ updated_at: options.fetch(:updated_at, updated_at)
99
+ )
100
+ end
101
+
102
+ def none = new_instance(none: true, total_items: 0, page: 1, page_size: 1).tap { |collection| collection.orm_objects = [] }
103
+ def not(**conditions) = merge(conditions.map { |key, value| build_negated_filter(key => value) })
104
+
105
+ def offset(value)
106
+ raise ArgumentError, 'Offset must be a non-negative integer' unless value.is_a?(Integer) && value >= 0
107
+
108
+ page_size.present? ? new_instance(offset: value, page: offset_page(value, page_size)) : new_instance(offset: value)
109
+ end
110
+
111
+ def order(*orders, **order_hash)
112
+ sort_strings = if order_hash.any?
113
+ order_hash.map { |attribute, direction| "#{attribute}:#{direction || :asc}" }
114
+ else
115
+ orders.map { |(attribute, direction)| "#{attribute}:#{direction || :asc}" }
116
+ end
117
+
118
+ new_instance(sort: sort_strings)
119
+ end
120
+
121
+ def orm_objects = @orm_objects ||= fetch_orm_objects
122
+ def pluck(attribute) = map(&attribute)
123
+
124
+ def present? = any?
125
+
126
+ def sample
127
+ collection = page_size.present? ? self : limit(10)
128
+ collection.to_a.sample
129
+ end
130
+ alias take sample
131
+
132
+ def to_a
133
+ @to_a ||= if @orm_objects || page_size.present?
134
+ orm_objects
135
+ else
136
+ raise ArgumentError, 'offset needs a limit, since the API pages' if @offset
137
+
138
+ first = new_instance(page: page || 1, page_size: PAGE_SIZE).tap(&:orm_objects)
139
+ pages = ((first.page + 1)..first.total_pages).map { |page| new_instance(page:, page_size: PAGE_SIZE).orm_objects }
140
+ @total_items = first.total_items
141
+ @updated_at = first.updated_at
142
+ @version = first.version
143
+
144
+ [first.orm_objects, *pages].flatten
145
+ end
146
+ end
147
+
148
+ def total_items
149
+ return 0 if @none
150
+ return @total_items if @total_items.present?
151
+
152
+ @total_items = (page_size.present? ? self : new_instance(page: 1, page_size: 1)).tap(&:orm_objects).total_items
153
+ end
154
+ alias length total_items
155
+ alias size total_items
156
+
157
+ def total_pages
158
+ return if total_items.blank? || page_size.blank?
159
+
160
+ (total_items.to_f / page_size).ceil
161
+ end
162
+
163
+ def where(**conditions)
164
+ conditions.blank? ? self : merge(conditions.map { |key, value| build_filter(key => value) })
165
+ end
166
+
167
+ protected
168
+
169
+ def fetch_orm_objects
170
+ return @orm_objects = [] if @none
171
+
172
+ operation = orm_class.collection_operation
173
+ variables = { filter: serialize(filter).presence, page:, page_size:, sort: }
174
+ data = orm_class.platform.query(operation, cache: true, **variables)[operation]
175
+
176
+ raise ResponseError, 'data[:page] is blank' if data&.dig(:page).blank?
177
+ raise ResponseError, 'data[:page_size] is blank' if data[:page_size].blank?
178
+ raise ResponseError, 'data[:total_items] is blank' if data[:total_items].blank?
179
+
180
+ @page = data[:page]
181
+ @page_size = data[:page_size]
182
+ @total_items = data[:total_items]
183
+ @updated_at = data[:updated_at]
184
+ @version = data[:version]
185
+
186
+ data[:items].map { |item| orm_class.new(item[:id], loaded: true, **item.except(:id)) }
187
+ end
188
+
189
+ private
190
+
191
+ # Contradictory alternatives match nothing, so they drop out
192
+ def alternatives(conditions)
193
+ conditions.filter_map { |condition| combine({}, condition.map { |key, value| build_filter(key => value) }) }
194
+ end
195
+
196
+ # Whether the filter knows the key only by its foreign key, e.g. `broker_carrier_id_null` but no `broker_carrier_null`
197
+ def association?(key)
198
+ arguments = GraphQLClient::Schema.types["#{orm_class.graphql_name}Filter"].arguments
199
+
200
+ arguments.exclude?("#{key.to_s.camelize(:lower)}Null") && arguments.key?("#{key.to_s.camelize(:lower)}IdNull")
201
+ end
202
+
203
+ def build_filter(**conditions)
204
+ conditions.each_with_object({}) do |(key, value), filter|
205
+ next filter[:or] = alternatives(value) if key == :or
206
+
207
+ value = integer_ids(value) if id_attribute?(key)
208
+ next filter[key] = value if key.to_s.match?(PREDICATE)
209
+
210
+ case value
211
+ when Array
212
+ next filter[:"#{key}_id_in"] = value.map { |record| integer_id(record.id) } if value.any? && value.all?(Base)
213
+
214
+ filter[:"#{key}_in"] = value
215
+ when Base
216
+ filter[:"#{key}_id_eq"] = integer_id(value.id)
217
+ when Collection
218
+ filter[:"#{key}_id_in"] = value.ids.map { |id| integer_id(id) }
219
+ when nil
220
+ filter[:"#{association?(key) ? "#{key}_id" : key}_null"] = true
221
+ when Range
222
+ filter[:"#{key}_gteq"] = value.begin unless value.begin.nil?
223
+ filter[:"#{key}_#{value.exclude_end? ? 'lt' : 'lteq'}"] = value.end unless value.end.nil?
224
+ else
225
+ filter[:"#{key}_eq"] = value
226
+ end
227
+ end
228
+ end
229
+
230
+ # Filter fields are ANDed, so only a condition that becomes one predicate can be negated
231
+ def build_negated_filter(**conditions)
232
+ conditions.each_with_object({}) do |(key, value), filter|
233
+ predicates = build_filter(key => value)
234
+ raise ArgumentError, "Can't negate #{key}: #{value.inspect}, negate each condition instead" if key == :or || predicates.many?
235
+
236
+ predicate, value = predicates.first
237
+ match = predicate.to_s.match(PREDICATE)
238
+ next filter[predicate] = !value if match[1] == 'null'
239
+
240
+ filter[:"#{match.pre_match}_#{NEGATIONS[match[1]] || NEGATIONS.key(match[1])}"] = value
241
+ end
242
+ end
243
+
244
+ # ANDs each condition's predicates with `base`, since a filter has one value per predicate, and conditions such as
245
+ # `id:` and `id_eq:` share one.
246
+ #
247
+ # @return [Hash, nil] Nil when the predicates contradict each other.
248
+ def combine(base, predicates_by_condition)
249
+ predicates_by_condition.flat_map(&:to_a).each_with_object(base.dup) do |(key, value), result|
250
+ old = result[key]
251
+ next result[key] = value if old.nil? || comparable(old) == comparable(value)
252
+ raise ArgumentError, 'A query takes one or:, combine the alternatives instead' if key == :or
253
+
254
+ match = key.to_s.match(PREDICATE)
255
+ case match[1]
256
+ when 'eq', 'null' then return nil
257
+ when 'gt', 'gteq' then result[key] = [old, value].max_by { |bound| comparable(bound) }
258
+ when 'in' then result[key] = old.select { |item| comparable(value).include?(comparable(item)) }
259
+ when 'lt', 'lteq' then result[key] = [old, value].min_by { |bound| comparable(bound) }
260
+ when 'not_eq'
261
+ not_in = :"#{match.pre_match}_not_in"
262
+ result.delete(key)
263
+ result[not_in] = [*result[not_in], old, value].uniq { |item| comparable(item) }
264
+ when 'not_in' then result[key] = (old + value).uniq { |item| comparable(item) }
265
+ else raise ArgumentError, "A query takes one #{key}"
266
+ end
267
+ end
268
+ end
269
+
270
+ # Values can be ISO 8601 strings, of dates or of times with different offsets, so they compare by date or instant
271
+ def comparable(value)
272
+ case value
273
+ when Array then value.map { |item| comparable(item) }
274
+ when Hash then value.transform_values { |item| comparable(item) }
275
+ when String then value.match?(/\A\d{4}-\d{2}-\d{2}\z/) ? Date.iso8601(value) : Time.iso8601(value)
276
+ when Symbol then value.to_s
277
+ else value
278
+ end
279
+ rescue ArgumentError
280
+ value
281
+ end
282
+
283
+ # `id` and `*_id`, with or without a predicate
284
+ def id_attribute?(key) = key.to_s.sub(PREDICATE, '').then { |attribute| attribute == 'id' || attribute.end_with?('_id') }
285
+
286
+ # GraphQL serializes IDs as strings, but ID filters take the integer column
287
+ def integer_id(id) = Integer(id, 10, exception: false) || id
288
+
289
+ def integer_ids(value)
290
+ case value
291
+ when Array then value.map { |id| integer_id(id) }
292
+ when Range then Range.new(integer_id(value.begin), integer_id(value.end), value.exclude_end?)
293
+ else integer_id(value)
294
+ end
295
+ end
296
+
297
+ def merge(predicates_by_condition)
298
+ merged = combine(filter, predicates_by_condition)&.then { |combined| satisfiable(combined) }
299
+
300
+ merged ? new_instance(filter: merged) : none
301
+ end
302
+
303
+ # The API pages, so the offset must start a page
304
+ def offset_page(offset, page_size)
305
+ raise ArgumentError, "offset #{offset} must be a multiple of the limit #{page_size}" unless (offset % page_size).zero?
306
+
307
+ (offset / page_size) + 1
308
+ end
309
+
310
+ # The API ignores empty lists, so an empty list matches nothing, and an empty exclusion excludes nothing
311
+ def satisfiable(conditions)
312
+ conditions = conditions.reject { |key, value| value == [] && key.end_with?('_not_in') }
313
+ return if conditions.any? { |_, value| value == [] }
314
+ return conditions unless conditions.key?(:or)
315
+
316
+ alternatives = conditions[:or].filter_map { |alternative| satisfiable(alternative) }
317
+ return if alternatives.empty?
318
+
319
+ alternatives.any?(&:empty?) ? conditions.except(:or) : conditions.merge(or: alternatives)
320
+ end
321
+
322
+ def serialize(value)
323
+ case value
324
+ when Array then value.map { |item| serialize(item) }
325
+ when Date, Time then value.iso8601
326
+ when Hash then value.transform_values { |item| serialize(item) }
327
+ when Symbol then value.to_s
328
+ else value
329
+ end
330
+ end
331
+ end
332
+ end
333
+ end
334
+ end
335
+ end
@@ -0,0 +1,12 @@
1
+ module FreightKit
2
+ module Platforms
3
+ class NextTMS
4
+ module ORM
5
+ class Customer < Base
6
+ def broker_customers = @broker_customers ||= orm::BrokerCustomer.where(customer: self)
7
+ def name = @name ||= attribute(:name)
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end