Skip to content

Commit

Permalink
feat(client): add support for stand-alone mode, that runs without def…
Browse files Browse the repository at this point in the history
…ining a model class
  • Loading branch information
ProGM committed Sep 21, 2023
1 parent 21a4b91 commit 299f1a6
Show file tree
Hide file tree
Showing 10 changed files with 244 additions and 14 deletions.
1 change: 1 addition & 0 deletions lib/any_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ module AnyQuery
autoload :Config
autoload :Adapters
autoload :Query
autoload :Client

included do
delegate_missing_to :@attributes
Expand Down
8 changes: 6 additions & 2 deletions lib/any_query/adapters/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
module AnyQuery
module Adapters
# @api private
# @abstract
class Base
def initialize(config)
@config = config.to_h
Expand Down Expand Up @@ -149,8 +150,11 @@ def parse_field_type_boolean(_, line)

# @abstract
class Config
def initialize(&block)
instance_eval(&block)
def initialize(params = {}, &block)
params.each do |key, value|
send(key, value) if respond_to?(key)
end
instance_eval(&block) if block_given?
end

def url(url)
Expand Down
6 changes: 3 additions & 3 deletions lib/any_query/adapters/csv.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ def to_h
end
end

def parse_fields(model)
def parse_fields(fields)
CSV.foreach(url, headers: true).map do |line|
result = {}
model.fields.each do |name, field|
fields.each do |name, field|
result[name] = parse_field(field, line[field[:source] || name.to_s])
end
result
Expand All @@ -30,7 +30,7 @@ def url
end

def load(model, select:, joins:, where:, limit:)
chain = parse_fields(model)
chain = parse_fields(model.fields)
chain = fallback_where(chain, where) if where.present?
chain = chain.first(limit) if limit.present?
chain = resolve_joins(chain, joins) if joins.present?
Expand Down
6 changes: 3 additions & 3 deletions lib/any_query/adapters/fixed_length.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ def initialize(config)
@file = File.open(url)
end

def parse_fields(model)
def parse_fields(fields)
@file.each_line.map do |line|
result = {}
last_index = 0
model.fields.each do |name, field|
fields.each do |name, field|
raw_value = line[last_index...(last_index + field[:length])]
result[name] = parse_field(field, raw_value)
last_index += field[:length]
Expand All @@ -40,7 +40,7 @@ def url
def load(model, select:, joins:, where:, limit:)
@file.rewind

chain = parse_fields(model)
chain = parse_fields(model.fields)
chain = fallback_where(chain, where) if where.present?
chain = chain.first(limit) if limit.present?
chain = resolve_joins(chain, joins) if joins.present?
Expand Down
17 changes: 12 additions & 5 deletions lib/any_query/adapters/http.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ class Http < Base
MAX_ITERATIONS = 1000
# @api private
class Config < Base::Config
def initialize(params = {}, &block)
super
params[:endpoints]&.each do |endpoint_params|
endpoint(endpoint_params[:scope], endpoint_params[:method], endpoint_params[:path], endpoint_params)
end
end

def endpoint(name, method, path, options = {})
@endpoints ||= {}
@endpoints[name] = { method:, path:, options: }
Expand Down Expand Up @@ -49,8 +56,8 @@ def parse_response(model, select, data)
end

# FIXME: Use common method
def load_single_from_list(data)
data.each_slice(50).flat_map do |slice|
def load_single_from_list(result_list)
result_list.each_slice(50).flat_map do |slice|
slice
.map { |data| Thread.new { run_http_single_query(data[:id], {}) } }
.each(&:join)
Expand Down Expand Up @@ -120,7 +127,7 @@ def run_http_list_query(raw_params)
end

def merge_params(endpoint, params, iteration, previous_response)
(endpoint[:options][:default_params] || {})
(endpoint.dig(:options, :default_params) || {})
.deep_merge(params)
.deep_merge(handle_pagination(endpoint, iteration, previous_response))
end
Expand All @@ -147,7 +154,7 @@ def unwrap(endpoint, data)
end

def unwrap_list(endpoint, data)
wrapper = endpoint[:options][:wrapper]
wrapper = endpoint.dig(:options, :wrapper)
return data unless wrapper

if wrapper.is_a?(Proc)
Expand All @@ -158,7 +165,7 @@ def unwrap_list(endpoint, data)
end

def unwrap_single(endpoint, data)
return data unless endpoint[:options][:single_wrapper]
return data unless endpoint.dig(:options, :single_wrapper)

data.map! do |row|
row.dig(*endpoint[:options][:single_wrapper])
Expand Down
2 changes: 1 addition & 1 deletion lib/any_query/adapters/sql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def initialize(config)
@rails_model = declare_model!
@rails_model.table_name = table_name
@rails_model.inheritance_column = :_sti_disabled
Object.const_set("AnyQuery#{table_name.classify}", @rails_model)
Object.const_set("AnyQuery#{table_name.classify}_#{SecureRandom.alphanumeric(4)}", @rails_model)
end

def declare_model!
Expand Down
43 changes: 43 additions & 0 deletions lib/any_query/client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true

module AnyQuery
class Client
def initialize(adapter:, params: {})
config = "AnyQuery::Adapters::#{adapter.to_s.classify}::Config".constantize.new(params)
@adapter = "AnyQuery::Adapters::#{adapter.to_s.classify}".constantize.new(config)
@params = params
@fields = {}

@params[:fields]&.each do |name, options|
@fields[name] = options
end
end

delegate_missing_to :all

def all
Query.new(ResultFactory.new(@fields), @adapter)
end

class Result
def initialize
@attributes = OpenStruct.new
end

delegate_missing_to :@attributes
end

# A class to instantiate results
class ResultFactory
attr_reader :fields

def initialize(fields)
@fields = fields
end

def new
Result.new
end
end
end
end
3 changes: 3 additions & 0 deletions lib/any_query/field.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
# frozen_string_literal: true

module AnyQuery
# Represents a field in a query
class Field
attr_reader :name, :type, :options

Expand Down
170 changes: 170 additions & 0 deletions spec/any_query/client_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# frozen_string_literal: true

require 'spec_helper'

describe AnyQuery::Client do
before do
stub_request(:get, 'http://example.com/articles?page=0&some_query_param=1')
.to_return(
headers: { content_type: 'application/json' },
body: JSON.dump(
{
items: [
{
id: 1,
user_id: 1,
name: 'some article'
},
{
id: 2,
user_id: 1,
name: 'some article 2'
}
]
}
)
)

stub_request(:get, 'http://example.com/articles?page=1&some_query_param=1')
.to_return(
headers: { content_type: 'application/json' },
body: '{ "items": [] }'
)
end

context 'with sql' do
subject do
described_class.new(
adapter: :sql,
params: {
url: 'sqlite3::memory:',
primary_key: :id,
table: 'users'
}
)
end

let(:fixed_length_client) do
described_class.new(
adapter: :fixed_length,
params: {
url: File.join(__dir__, '../fixtures/sample.txt'),
primary_key: :id,
fields: {
id: { type: :integer, length: 4 },
user_id: { type: :integer, length: 4 },
title: { type: :string, length: 30 },
body: { type: :string, length: 100 },
status: { type: :integer, length: 1 },
created_at: { type: :datetime, format: '%Y%m%d%H%M%S', length: 14 }
}
}
)
end

it 'returns records' do
subject
# Re-create schema since it's a new connection
Schema.create
result = subject.all.to_a
expect(result).to have(1).items
expect(result[0].email).to eq('[email protected]')

joined_result = fixed_length_client.joins(subject, :id, :user_id, into: :user).to_a

expect([joined_result.first.user_id, joined_result.first.user.email]).to eq([1, '[email protected]'])
end
end

context 'with http' do
subject do
described_class.new(
adapter: :http,
params: {
url: 'http://example.com',
primary_key: :id,
endpoints: [
{
scope: :list,
method: :get,
path: '/articles',
wrapper: [:items],
pagination: { type: :page },
default_params: {
query: { some_query_param: 1 },
headers: { 'Authorization': 'some' }
}
},
{
scope: :show,
method: :get,
path: '/articles/{id}'
}
]
}
)
end

it 'returns records' do
expect(subject.all.to_a).to have(2).items
end
end

context 'with csv' do
subject do
described_class.new(
adapter: :csv,
params: {
url: File.join(__dir__, '../fixtures/sample.csv'),
primary_key: :id,
fields: {
id: { type: :integer },
user_id: { type: :integer },
title: { type: :string },
body: { type: :string },
status: { type: :integer },
created_at: { type: :datetime, format: '%Y-%m-%d %H:%M:%S' }
}
}
)
end
let(:result) { subject.all.to_a }

it 'returns records' do
expect(result).to have(2).items
end
end

context 'with fixed length exports' do
subject do
described_class.new(
adapter: :fixed_length,
params: {
url: File.join(__dir__, '../fixtures/sample.txt'),
primary_key: :id,
fields: {
id: { type: :integer, length: 4 },
user_id: { type: :integer, length: 4 },
title: { type: :string, length: 30 },
body: { type: :string, length: 100 },
status: { type: :integer, length: 1 },
created_at: { type: :datetime, format: '%Y%m%d%H%M%S', length: 14 }
}
}
)
end

let(:result) { subject.all.to_a }

it 'returns records' do
expect(result).to have(2).items

expect(result[1].id).to eq(2)
expect(result[1].user_id).to eq(1)
expect(result[1].title).to eq('this is another sample')
expect(result[1].body).to eq('this is an example of a body for an article that is very long and dirty')
expect(result[1].status).to eq(2)
expect(result[1].created_at).to eq('2023-12-31T19:00:00Z'.to_datetime)
end
end
end
2 changes: 2 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

# require 'rails'
# require 'action_controller/railtie'
# require 'action_mailer/railtie'
Expand Down

0 comments on commit 299f1a6

Please sign in to comment.