Question: How to save GraphQL query to my local/postgres database?
(let's assume I just want to save one or two fields -- I'll do more, but I just am not sure how to go about it to begin with.)
Background:
I have an embedded shopify app that works with webhooks to consume orders. I run a job, and the data is stored perfectly in my database.
I’ve set up a super simple app that makes a graphql query in a model. I’ve set it up so I can see the json object in a view. But am not 100% clear how I go from graphql api query -> response (in model - at least for now) -> save data attributes to my database
I am testing this as a new app. So, I am not splitting up models/controllers, etc., just using one model for the moment
I guess I am just a bit lost. Do I need to run a job? Or is this something I can do in the controller (or from the model).
Model
shop.rb
class Shop < ActiveRecord::Base
include ShopifyApp::ShopSessionStorage
has_many :orders
def api_version
ShopifyApp.configuration.api_version
end
session = ShopifyAPI::Session.new(domain: "bryanbeshore.myshopify.com", token: Shop.first.shopify_token, api_version: "2020-04")
ShopifyAPI::Base.activate_session(session)
client = ShopifyAPI::GraphQL.client
SHOP_NAME_QUERY = client.parse <<-'GRAPHQL'
{
shop {
name
}
orders(first: 100) {
edges {
node {
id
name
createdAt
shippingAddress {
address1
address2
city
province
provinceCode
zip
}
}
}
}
}
GRAPHQL
end
Controller
home_controller.rb
class HomeController < AuthenticatedController
def index
client = ShopifyAPI::GraphQL.client
#shop_orders = client.query(Shop::SHOP_NAME_QUERY).data.to_json
end
end
View
app/views/home/index.html.erb
<p><%= #shop_orders %></p>
Current Schema
ActiveRecord::Schema.define(version: 2020_05_06_181457) do
create_table "orders", force: :cascade do |t|
t.string "shopify_order_id", null: false
t.string "shopify_order_name", default: ""
t.datetime "shopify_order_created_at"
t.integer "shop_id"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["shop_id"], name: "index_orders_on_shop_id"
t.index ["shopify_order_id"], name: "index_orders_on_shopify_order_id", unique: true
end
create_table "shops", force: :cascade do |t|
t.string "shopify_domain", null: false
t.string "shopify_token", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["shopify_domain"], name: "index_shops_on_shopify_domain", unique: true
end
end
You are querying Shop orders, so I'd create an Order model / orders table, make sure it belongs to a shop.
rails g model Order payload:jsonb shop:references, for this example I'm just creating one jsonb field that we will dump the whole JSON object into.
rails db:migrate
Add belongs_to :shop in models/order.rb
Make sure that Shop model has this has_many :orders, usually rails add it for you
Now, when you get your JSON payload from Shopify, loop through it and create a new record for each order you got.
So in the method you use to query orders add this.
shopify_json_response.each do |item|
orders.create(payload: item)
end
More or less, that should do the trick.
You don't need a background job for this, however background jobs are ideal when you want to process data that doesn't need to be processed right away.
Related
I'm trying to save data fetched from Sellix API into the db in my Rails application.
Basically, there are 4 models: products, coupons, orders, and feedback.
Sellix has its own unique id on every object called "uniqid" so I decided to use it as the primary key in my models as well.
For some models, I want to save references for other tables. For example, I want to have a coupon as a reference for orders to find out which coupon has been used when placing that order.
This is how two schemas are now:
create_table "coupons", id: false, force: :cascade do |t|
t.string "uniqid", null: false
t.string "code"
t.decimal "discount"
t.integer "used"
t.datetime "expire_at"
t.integer "created_at"
t.integer "updated_at"
t.integer "max_uses"
t.index ["uniqid"], name: "index_coupons_on_uniqid", unique: true
end
create_table "orders", id: false, force: :cascade do |t|
t.string "uniqid", null: false
t.string "order_type"
t.decimal "total"
t.decimal "crypto_exchange_rate"
t.string "customer_email"
t.string "gateway"
t.decimal "crypto_amount"
t.decimal "crypto_received"
t.string "country"
t.decimal "discount"
t.integer "created_at"
t.integer "updated_at"
t.string "coupon_uniqid"
t.index ["uniqid"], name: "index_orders_on_uniqid", unique: true
end
The coupon_uniqid on orders table is the reference to the relevant coupon.
The order object on Sellix API already has that reference so currently I can save it this way.
But when I display all orders, I have to use Coupon.find_by(uniqid: order.coupon_uniqid) and it always iterate through every coupon record in the local db to find it as below.
CACHE Coupon Load (0.0ms) SELECT "coupons".* FROM "coupons" WHERE "coupons"."uniqid" = $1 LIMIT $2 [["uniqid", "62e95dea17de385"], ["LIMIT", 1]]
I can get rid of that if I can keep the coupon reference instead of the uniqid.
That's basically what I want to figure out.
YAGNI. A better approach that you should consider is to just have your own primary key and treat the uniqid as a secondary identifier to be used when looking up records based on their external id.
That way everything just works with minimal configuration.
If you really want to break the conventions you can configure the type and name of the primary key when creating the table:
create_table :orders, id: :string, primary_key: :uniqid do |t|
# ...
end
class Order < ApplicationRecord
self.primary_key = :uniqid
end
Since this column won't automatically generate primary keys you'll need to deal with that in all your tests as well.
You then have to provide extra configuration when creating foreign key columns so that they are the same type and point to the right column on the other table:
class AddOrderIdToCoupons < ActiveRecord::Migration[7.0]
def change
add_reference :coupons, :order,
type: :string, # default is bigint
null: false,
foreign_key: { primary_key: "uniqid" }
end
end
And you also need to add configuration to all your assocations:
class Coupon < Application
belongs_to :order, primary_key: "uniqid"
end
class Order < Application
has_many :coupons, primary_key: "uniqid"
end
I have a custom rails validator below that checks whether a new booking is available within the existing bookings based on the start and end date. I'm struggling to figure how I can add a separate 'room' validation. Each booking is for a room, so I want to make sure that the date range is only for the specified room and not for ALL bookings, just the bookings for the individual room. Any thoughts?
validates :start_date, :end_date, :presence => true, availability: true
class AvailabilityValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
bookings = Booking.where(void:'f')
date_ranges = bookings.map { |b| b.start_date..b.end_date }
date_ranges.each do |range|
if range.include? value
record.errors.add(attribute, "not available")
end
end
end
end
EDIT:
Following up on the schema. Fairly simple, all bookings are made by parent associated users ("user_id"), and room is simply another integer attribute within the record (there are 10 rooms).
create_table "bookings", force: :cascade do |t|
t.datetime "start_date"
t.datetime "end_date"
t.boolean "void", default: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.bigint "user_id"
t.integer "room"
t.index ["user_id"], name: "index_bookings_on_user_id"
end
I have a 'work_space' model that has_many 'reviews'. In my 'reviews' model, users are able to rate a work_space with an integer stored in a :rating column. I created a method in my WorkSpace model to calculate the average of all reviews for a particular work_space.
class WorkSpace < ApplicationRecord
def avg_rating
reviews.average(:rating).to_f
end
I added this method to my WorkSpace serializer and it returns the data I am looking for.
I then created a method in my WorkSpace model that would calculate the top 5 highest 'rated' work_spaces.
class WorkSpace < ApplicationRecord
scope :by_average_for, ->(column) {
joins(:reviews)
.group('work_spaces.id')
.order(Arel.sql("AVG(reviews.#{column}) desc"))
.having("AVG(reviews.#{column}) > 4", column) if column
}
def top_avg_rating
WorkSpace.by_average_for(:rating).limit(5)
end
The problem I have here is that when I retrieve the data for top_avg_rating, it lists the correct work_spaces, but I only get the data that is present in my WorkSpace schema and not the added data I normally get from my WorkSpace serializer. Most importantly, avg_rating is not present.
create_table "work_spaces", force: :cascade do |t|
t.string "place_id"
t.float "lat"
t.float "lng"
t.bigint "user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "name"
t.string "address"
t.string "photo"
t.integer "cached_votes_total", default: 0
t.integer "cached_votes_score", default: 0
t.integer "cached_votes_up", default: 0
t.integer "cached_votes_down", default: 0
t.integer "cached_weighted_score", default: 0
t.integer "cached_weighted_total", default: 0
t.float "cached_weighted_average", default: 0.0
t.index ["user_id"], name: "index_work_spaces_on_user_id"
end
I figured it would be a good idea to store/cache the data I am getting from my WorkSpace model into a new column. So I added a column to my WorkSpace model.
t.float "avgrating"
I then attempted to store the data for my avg_rating into that column.
class WorkSpace < ApplicationRecord
def update_rating
reviews.average(:rating).to_f
update(avgrating: reviews.average(:rating).to_f)
end
This actually works, but I have to add this method to my serializer in order to update the column. Is there another way to make sure this method is called? I looked into after_save options and caching, but I couldn't put anything together that made sense.
I feel like there has to be a better way to achieve this and I have a strong feeling that I am probably doing this all wrong.
I need to create this same logic for a few more fields from my reviews table, so I'm hoping to settle on a good way to do this before I move forward.
Add select to by_average_for
scope :by_average_for, ->(column) {
avg_rating = "AVG(reviews.#{column})"
joins(:reviews)
.select("work_spaces.*, #{avg_rating} AS avg_rating")
.group('work_spaces.id')
.order(Arel.sql("#{avg_rating} desc"))
.having("#{avg_rating} > 4", column) if column
}
then you can access avg_rating
I am beginner in Ruby-on-Rails and I started to investigate ActiveRecords. And I run into one problem.
I have two tables: todo_lists and users. The first one has following records: title, description, user_id, deadline. And the second one has only one record: name. I want to get a table that contains following records: title, description, name, deadline, i.e. combine two tables and put user instead of user_id. I try to solve this problem using joins:
TodoList.joins(:user).select("todo_lists.title, todo_lists.description, users.name, todo_lists.deadline")
But I get the new ActiveRecord::Relation without users.name:
[#<TodoList id: nil, title: "This is title", description: "This is description", deadline: "2020-03-13 18:59:58">]
This is my todo_list.rb and user.rb files:
class TodoList < ApplicationRecord
belongs_to :user
end
class User < ApplicationRecord
has_many :todo_lists
end
schema.rb file:
ActiveRecord::Schema.define(version: 2020_03_13_183504) do
create_table "todo_lists", force: :cascade do |t|
t.string "description", null: false
t.integer "user_id", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.boolean "is_disabled"
t.datetime "deadline"
end
create_table "users", force: :cascade do |t|
t.string "name", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
end
add_foreign_key "todo_lists", "users"
end
Can anyone help me to solve this problem?
If what you need is to access the user name from each object in the TodoList::ActiveRecord_Relation, you can use AS and give the proper name, which allows you to use it as a method from a single record on the ActiveRecord_Relation holding the user name:
TodoList.joins(:user).select("..., users.name AS user_name").first.user_name
Otherwise you can use as_json, which returns an array containing the data of every record as a hash, where each key is the column and the value, the corresponding values in the select statement:
TodoList.joins(:user).select("todo_lists.description, users.name").as_json
# [{"id"=>nil, "description"=>"1st", "name"=>"user1", ...},
# {"id"=>nil, "description"=>"2nd", "name"=>"user2", ...}]
Sebastian Palma's is a good answer to the question as asked. It asked specifically how to solve this problem using joins.
That said, I think the traditional ActiveRecord approach to this problem is to use your models and associations.
You can add a user_name method to the TodoList model like this:
class TodoList < ApplicationRecord
belongs_to :user
def user_name
user.name
end
end
Now, any TodoList instance will return the name of its associated User regardless of how it was queried.
To avoid an n + 1 query scenario, you can use preload(:user) when querying TodoLists. For example, this code will make 2 queries to get the last 10 TodoLists and their associated Users.
TodoList.preload(:user).last(10)
I want to show a text summary for a model in a Rails application.
Currently I'm doing it like this:
class ServiceOrder < ApplicationRecord
has_many :items, class_name: 'ServiceOrderItem',
dependent: :destroy,
inverse_of: :service_order
def link_text
items.left_outer_joins(:product)
.select("string_agg(coalesce(products.description, service_order_items.description), '; ') as description")
.group("service_order_items.service_order_id")
.map(&:description)
.first
end
end
class ServiceOrderItem < ApplicationRecord
belongs_to :service_order, inverse_of: :items
belongs_to :product, optional: true
end
class Product < ApplicationRecord
end
What bothers me is that I'm trying to select a single value, and not a model.
This query does return a "fake" model and extract the value I want, but it's kind of hacky:
Add proper relations I need
items.left_outer_joins(:product)
Add the select value I want
.select("string_agg(coalesce(products.description, service_order_items.description), '; ') as description")
Add group by clause
.group("service_order_items.service_order_id")
Execute the query and extract the description of the "fake" model returned
.map(&:description)
I know this query only returns a single result, but it builds an array with all results, so I extract the single result out of the array
.first
The query I want is this:
select string_agg(coalesce(products.description, service_order_items.description), '; ')
from service_order_items
left outer join products on service_order_items.product_id = products.id
where service_order_items.service_order_id = :id
group by service_order_items.service_order_id;
And this is the query I'm generating, the problem is that the result is enclosed in a model object, then I transform it into an array and then I extract the value I want.
So, how do I tell active record to select a single raw value and not a list of models?
By the way, adding .first before .map doesn't work because it includes an order by in the executed SQL that I can't have (order by service_order_items.id).
The schema:
create_table "products", force: :cascade do |t|
t.integer "organization_id"
t.string "code"
t.string "description"
t.string "brand"
t.string "unit_of_measure"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.decimal "selling_price"
t.index ["organization_id"], name: "index_products_on_organization_id", using: :btree
end
create_table "service_order_items", force: :cascade do |t|
t.integer "service_order_id"
t.decimal "quantity"
t.string "description"
t.integer "product_id"
t.decimal "unit_price"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["product_id"], name: "index_service_order_items_on_product_id", using: :btree
t.index ["service_order_id"], name: "index_service_order_items_on_service_order_id", using: :btree
end
create_table "service_orders", force: :cascade do |t|
t.integer "organization_id"
t.text "description"
t.integer "state_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "customer_id"
t.integer "sequential_id"
t.date "start_date"
t.date "end_date"
t.index ["customer_id"], name: "index_service_orders_on_customer_id", using: :btree
t.index ["organization_id"], name: "index_service_orders_on_organization_id", using: :btree
t.index ["state_id"], name: "index_service_orders_on_state_id", using: :btree
end
New answer
The need to use the description on service_order_items if there isn't a product makes this a little tricky. If you want to keep your custom SQL, it should be possible to use pluck with the same text as your select (minus the as description part):
def link_text
items.left_outer_joins(:product)
.group("service_order_items.service_order_id")
.pluck("string_agg(coalesce(products.description, service_order_items.description), '; ')")
.first
end
You also mentioned that you couldn't use first before map because it introduced an undesired order; you could try using take instead of first to avoid that, in which case you wouldn't need pluck.
Note that in either case you're introducing some dependencies on the table names, which could cause problems in more complex queries that require table aliases. If you want to go for less custom SQL,
the most direct way I can think of is to add the following method (probably with a name that better fits your application) to ServiceOrderItem:
def description_for_link_text
product.try(:description) || description
end
Then in ServiceOrder:
def link_text
items.includes(:product).map(&:description_for_link_text).join('; ')
end
The includes(:product) should avoid the N+1 issue where you do one query to get the items and then another query for each product. If you have a page that's displaying this text for multiple service orders, you have to deal with another level of this; often you have to declare a whole bunch of tables in includes even if they're declared in the link_text method.
service_orders = ServiceOrder.some_query_or_scope.includes(items: :product)
service_orders.each { |so| puts so.link_text }
If you do this, I don't think you actually have to have the includes in link_text itself, but if you removed it from there and you called link_text in any other situation, you'd get the N+1 issue again.
Original answer
I'm a bit confused by how your schema fits together: do service_orders and items have a one-to-many relationship, or a many-to-many relationship? How does products relate to items? And I don't have quite enough reputation to comment to ask.
In general, you can use pluck to get an array of values with just the attributes you want. I don't know off the top of my head if it works on virtual attributes, but you may be able to define has_many :through relationships so that you don't need to define string_agg(products.description, '; ') as description to join the strings together. That is, if your ServiceOrder model is able to have a products association like:
has_many :items
has_many :products, through: :items
then you could then just define link_text as products.pluck(:description).join("; "). You may need to play around with your has_many :through definition in order to get it to work right with your schema. Also, doing it this way does mean you have to watch out for potential N+1 query issues; see the Rails guide section on eager loading for how to address that.