SQL Express Query UNION Rails
I have a query that works well but is expressed in SQL. I would like to have the same query expressed using the ActiveRecord Query Interface (Arel would do fine too). The request should preferably return ActiveRecord :: Relation, or at least its result should be convertible to an array of Customer models.
The goal is to get company's
customers
unrelated import_logs
with remote_type = 'account'
, as well customers
as import_log
with remote_type = 'account'
and status = 'pending'
.
A customer
may not have at all, import_logs
or have import_log
for each, remote_type
or only for some remote_types
. There can only be one associated import_log
with a specific meaning remote_type
.
This reflects the requirement that a customer
can be imported like account
or contact
or both, and import_log
tracked the import status.
Although it import_log
has a polymorphic relationship to customer
, this is not relevant to the task.
Existing request:
Customer.find_by_sql(
<<-SQL
SELECT
customers.*
FROM
customers
WHERE
company_id = #{@company.id}
AND NOT EXISTS
( SELECT *
FROM import_logs
WHERE import_logs.importable_id = customers.id
AND import_logs.importable_type = 'Customer'
AND import_logs.remote_type = 'account'
)
UNION
SELECT
customers.*
FROM
customers,
import_logs
WHERE
import_logs.importable_id = customers.id AND
import_logs.importable_type = 'Customer' AND
company_id = #{@company.id} AND
import_logs.remote_type = 'account' AND
import_logs.status = 'pending';
SQL
)
Relevant parts of the ImportLog model:
create_table "import_logs", force: true do |t|
t.integer "importable_id"
t.string "importable_type"
t.string "status", default: "pending", null: false
t.string "remote_type"
...
end
add_index "import_logs", ["importable_id", "importable_type", "remote_type"], unique: true ...
class ImportLog < ActiveRecord::Base
...
belongs_to :importable, polymorphic: true
...
end
Relevant parts of the client model:
create_table "customers", force: true do |t|
t.integer "company_id"
...
end
class Customer < ActiveRecord::Base
...
belongs_to :company
has_many :import_logs, as: :importable
...
end
And the company's models, just in case:
class Company < ActiveRecord::Base
...
has_many :customers
...
end
source to share
merge
associations
In fact, there is only one association, which is driven by a query constant.
"customers"."company_id" = #{@company.id}
This is the same as:
.merge(@company.customers)
... and it looks a little safer and smarter
Arel tables
We need it soon.
customers = Customer.arel_table
NOT EXISTS ...
subquery
Arel can do this, the only less obvious thing is how to reference an external table:
ne_subquery = ImportLog.where(
importable_type: Customer.to_s,
importable_id: customers[:id],
remote_type: 'account'
).exists.not
This leads to the fact that Arel AST can submit ' where
-statement to Rails .
Now both questions become obvious:
first = @company.customers.where(ne_subquery)
second = @company.customers.joins(:import_logs).merge(
ImportLog.where(
# importable_id: customers[:id], # `joins` already does it
importable_type: Customer.to_s,
remote_type: 'acoount',
status: 'pending'
)
)
This is pretty much a 1 to 1 conversion.
Union
This is the tricky part and the only solution I have found has an awfully ugly syntax and outputs a bit different query. Given A union B
, we can only build select X.* from (A union B) X
. The effect is the same.
Okay, let it get to him:
Customer.from(
customers.create_table_alias(
first.union(second),
Customer.table_name
)
)
Of course, to make this request more readable, you must:
- Place it as scope in a class
Customer
- Divide the reusable parts into areas and associations.
source to share
Based on the code suggested by @ D-side, I was able to reach a working solution. Here is the originally suggested code:
customers = Customer.arel_table
ne_subquery = ImportLog.where(
importable_type: Customer.to_s,
importable_id: customers['id'],
remote_type: 'account'
).exists.not
first = @company.customers.where(ne_subquery)
second = @company.customers.joins(:import_logs).merge(
ImportLog.where(
importable_type: Customer.to_s,
remote_type: 'account',
status: 'pending'
)
)
Customer.from(
customers.create_table_alias(
first.union(second),
Customer.table_name
)
)
Doing so results in this error:
PG::ProtocolViolation: ERROR: bind message supplies 0 parameters, but prepared statement "" requires 1
: SELECT "customers".* FROM ( SELECT "customers".* FROM "customers" WHERE "customers"."company_id" = $1 \
AND (NOT (EXISTS (SELECT "import_logs".* FROM "import_logs" WHERE "import_logs"."importable_type" = 'Customer' \
AND "import_logs"."importable_id" = "customers"."id"))) UNION SELECT "customers".* FROM "customers" \
INNER JOIN "import_logs" ON "import_logs"."importable_id" = "customers"."id" \
AND "import_logs"."importable_type" = 'Customer' WHERE "customers"."company_id" = $1 \
AND "import_logs"."importable_type" = 'Customer' AND "import_logs"."remote_type" = 'contact' \
AND "import_logs"."status" = 'pending' ) "customers"
This error, I believe, is an expression of Rails issue # 20077 , which is currently unresolved. Since the problem is with parameter binding, this makes the binding more explicit. Here's a working solution:
customers = Customer.arel_table
ne_subquery = ImportLog.where(
importable_type: Customer.to_s,
importable_id: customers['id'],
remote_type: 'account'
).exists.not
first = Customer.where(ne_subquery).where(company_id: @company.id)
second = Customer.joins(:import_logs).merge(
ImportLog.where(
importable_type: Customer.to_s,
remote_type: 'account',
status: 'pending'
)
).where(company_id: @company.id)
Customer.from(
customers.create_table_alias(
first.union(second),
Customer.table_name
)
)
Note that .where(company_id: @company.id)
apply explicitly, and requests first
and second
start without registration.
source to share