Skip to content

Commit 875f0e4

Browse files
authored
"All Articles" list (#2224)
as well as Explainers under one list. Checklist: - [x] There is already an `articles` field under `TeamType`: We need to make sure that the `article_type` argument is not mandatory anymore. - [x] Implement a `filtered_articles` method for the `Team` model, that uses SQL `UNION` and reuse the logic for explainers and fact-checks implemented by methods `filtered_explainers` and `filtered_fact_checks`, respectively. - [x] Guard against SQL injections. - [x] Test for all filters. - [x] Test for all sorting options. - [x] Avoid N + 1 queries problem. Reference: CV2-5007.
1 parent ae4b6b4 commit 875f0e4

File tree

7 files changed

+131
-27
lines changed

7 files changed

+131
-27
lines changed

.rubocop.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ Metrics/CyclomaticComplexity:
224224
A complexity metric that is strongly correlated to the number
225225
of test cases needed to validate a method.
226226
Enabled: true
227-
Max: 13
227+
Max: 14
228228

229229
Metrics/LineLength:
230230
Description: 'Limit lines to 80 characters.'

app/graph/types/team_type.rb

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -304,9 +304,10 @@ def tipline_requests(from_timestamp:, to_timestamp:)
304304
end
305305

306306
field :articles, ::ArticleUnion.connection_type, null: true do
307-
argument :article_type, GraphQL::Types::String, required: true, camelize: false
307+
argument :article_type, GraphQL::Types::String, required: false, camelize: false
308308

309309
# Sort and pagination
310+
argument :limit, GraphQL::Types::Int, required: false, default_value: 10
310311
argument :offset, GraphQL::Types::Int, required: false, default_value: 0
311312
argument :sort, GraphQL::Types::String, required: false, default_value: 'title'
312313
argument :sort_type, GraphQL::Types::String, required: false, camelize: false, default_value: 'ASC'
@@ -331,13 +332,18 @@ def articles(**args)
331332
sort = args[:sort].to_s
332333
order = [:title, :language, :updated_at, :id].include?(sort.downcase.to_sym) ? sort.downcase.to_sym : :title
333334
order_type = args[:sort_type].to_s.downcase.to_sym == :desc ? :desc : :asc
334-
articles = Explainer.none
335-
if args[:article_type] == 'explainer'
336-
articles = object.filtered_explainers(args)
337-
elsif args[:article_type] == 'fact-check'
338-
articles = object.filtered_fact_checks(args)
335+
if args[:article_type].blank?
336+
limit = context[:current_arguments][:first] || args[:limit]
337+
object.filtered_articles(args, limit.to_i, args[:offset].to_i, order, order_type)
338+
else
339+
articles = nil
340+
if args[:article_type] == 'explainer'
341+
articles = object.filtered_explainers(args)
342+
elsif args[:article_type] == 'fact-check'
343+
articles = object.filtered_fact_checks(args)
344+
end
345+
articles.offset(args[:offset].to_i).order(order => order_type)
339346
end
340-
articles.offset(args[:offset].to_i).order(order => order_type)
341347
end
342348

343349
field :articles_count, GraphQL::Types::Int, null: true do

app/models/team.rb

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,31 @@ def available_newsletter_header_types
487487
available
488488
end
489489

490+
def filtered_articles(filters = {}, limit = 10, offset = 0, order = 'created_at', order_type = 'DESC')
491+
# Re-use existing methods to build the SQL queries for fact-checks and for explainers
492+
# We must include all columns we may need to sort by
493+
columns = [:id, :title, :language, :created_at, :updated_at]
494+
fact_checks = self.filtered_fact_checks(filters, false).select(["'FactCheck' AS type"] + columns.collect{ |column| "fact_checks.#{column}" })
495+
explainers = self.filtered_explainers(filters).select(["'Explainer' AS type"] + columns.collect{ |column| "explainers.#{column}" })
496+
497+
# Combine the two queries using SQL UNION
498+
query = <<~SQL
499+
SELECT type, id, title, language, created_at, updated_at FROM ( #{fact_checks.to_sql} UNION #{explainers.to_sql} ) AS articles
500+
ORDER BY #{order} #{order_type} LIMIT ? OFFSET ?
501+
SQL
502+
results = ActiveRecord::Base.connection.exec_query(ActiveRecord::Base.sanitize_sql([query, limit, offset])).map{ |row| OpenStruct.new(row) }
503+
504+
# Pre-load objects in memory in order to avoid an N + 1 queries problem
505+
preloaded_results = {}
506+
fact_check_ids = results.select{ |result| result.type == 'FactCheck' }.map(&:id)
507+
preloaded_results['FactCheck'] = FactCheck.includes(:claim_description).where(id: fact_check_ids).where('claim_descriptions.team_id' => self.id).to_a.to_h{ |object| [object[:id], object] }
508+
explainer_ids = results.select{ |result| result.type == 'Explainer' }.map(&:id)
509+
preloaded_results['Explainer'] = Explainer.where(id: explainer_ids, team_id: self.id).to_a.to_h{ |object| [object[:id], object] }
510+
511+
# Load full objects from pre-loaded list while keeping the order
512+
results.collect{ |object| preloaded_results[object.type][object.id] }
513+
end
514+
490515
def filtered_explainers(filters = {})
491516
query = self.explainers
492517

@@ -513,8 +538,9 @@ def filtered_explainers(filters = {})
513538
query
514539
end
515540

516-
def filtered_fact_checks(filters = {})
517-
query = FactCheck.includes(:claim_description).where('claim_descriptions.team_id' => self.id)
541+
def filtered_fact_checks(filters = {}, include_claim_descriptions = true)
542+
query = (include_claim_descriptions ? FactCheck.includes(:claim_description) : FactCheck.joins(:claim_description))
543+
query = query.where('claim_descriptions.team_id' => self.id)
518544

519545
# Filter by standalone
520546
query = query.left_joins(claim_description: { project_media: :media }).where('claim_descriptions.project_media_id IS NULL OR medias.type = ?', 'Blank') if filters[:standalone]
@@ -560,9 +586,9 @@ def filtered_fact_checks(filters = {})
560586
def filter_by_keywords(query, filters, type = 'FactCheck')
561587
tsquery = Team.sanitize_sql_array(["websearch_to_tsquery(?)", filters[:text]])
562588
if type == 'FactCheck'
563-
tsvector = "to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(summary, '') || ' ' || coalesce(url, '') || ' ' || coalesce(claim_descriptions.description, '') || ' ' || coalesce(claim_descriptions.context, ''))"
564-
else
565-
tsvector = "to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(description, '') || ' ' || coalesce(url, ''))"
589+
tsvector = "to_tsvector('simple', coalesce(fact_checks.title, '') || ' ' || coalesce(fact_checks.summary, '') || ' ' || coalesce(fact_checks.url, '') || ' ' || coalesce(claim_descriptions.description, '') || ' ' || coalesce(claim_descriptions.context, ''))"
590+
elsif type == 'Explainer'
591+
tsvector = "to_tsvector('simple', coalesce(explainers.title, '') || ' ' || coalesce(explainers.description, '') || ' ' || coalesce(explainers.url, ''))"
566592
end
567593
query.where(Arel.sql("#{tsvector} @@ #{tsquery}"))
568594
end

lib/relay.idl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13183,7 +13183,7 @@ type Team implements Node {
1318313183
Returns the elements in the list that come after the specified cursor.
1318413184
"""
1318513185
after: String
13186-
article_type: String!
13186+
article_type: String
1318713187

1318813188
"""
1318913189
Returns the elements in the list that come before the specified cursor.
@@ -13202,6 +13202,7 @@ type Team implements Node {
1320213202
Returns the last _n_ elements from the list.
1320313203
"""
1320413204
last: Int
13205+
limit: Int = 10
1320513206
offset: Int = 0
1320613207
publisher_ids: [Int]
1320713208
rating: [String]

public/relay.json

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69221,18 +69221,26 @@
6922169221
"name": "article_type",
6922269222
"description": null,
6922369223
"type": {
69224-
"kind": "NON_NULL",
69225-
"name": null,
69226-
"ofType": {
69227-
"kind": "SCALAR",
69228-
"name": "String",
69229-
"ofType": null
69230-
}
69224+
"kind": "SCALAR",
69225+
"name": "String",
69226+
"ofType": null
6923169227
},
6923269228
"defaultValue": null,
6923369229
"isDeprecated": false,
6923469230
"deprecationReason": null
6923569231
},
69232+
{
69233+
"name": "limit",
69234+
"description": null,
69235+
"type": {
69236+
"kind": "SCALAR",
69237+
"name": "Int",
69238+
"ofType": null
69239+
},
69240+
"defaultValue": "10",
69241+
"isDeprecated": false,
69242+
"deprecationReason": null
69243+
},
6923669244
{
6923769245
"name": "offset",
6923869246
"description": null,

test/controllers/graphql_controller_12_test.rb

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -407,18 +407,22 @@ def teardown
407407
assert_equal [fc.id], data.collect{ |edge| edge['node']['dbid'] }
408408
end
409409

410-
test "should get team articles (all)" do
410+
test "should get all team articles" do
411411
Sidekiq::Testing.fake!
412412
authenticate_with_user(@u)
413413
pm = create_project_media team: @t
414414
cd = create_claim_description project_media: pm
415-
create_fact_check claim_description: cd, tags: ['foo', 'bar']
416-
create_explainer team: @t
417-
query = "query { team(slug: \"#{@t.slug}\") { articles_count } }"
415+
create_fact_check claim_description: cd, title: 'Foo'
416+
sleep 1
417+
create_explainer team: @t, title: 'Test'
418+
sleep 1
419+
create_explainer team: @t, title: 'Bar'
420+
query = "query { team(slug: \"#{@t.slug}\") { articles(first: 2, sort: \"title\", sort_type: \"asc\") { edges { node { ... on Explainer { title }, ... on FactCheck { title } } } }, articles_count } }"
418421
post :create, params: { query: query, team: @t.slug }
419422
assert_response :success
420-
team = JSON.parse(@response.body)['data']['team']
421-
assert_equal 2, team['articles_count']
423+
data = JSON.parse(@response.body)['data']
424+
assert_equal 3, data.dig('team', 'articles_count')
425+
assert_equal ['Bar', 'Foo'], data.dig('team', 'articles', 'edges').collect{ |node| node.dig('node', 'title') }
422426
end
423427

424428
test "should create api key" do

test/models/team_2_test.rb

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1591,6 +1591,65 @@ def setup
15911591
assert_equal [], t.search_for_similar_articles('Test')
15921592
end
15931593

1594+
test "should return all articles" do
1595+
Sidekiq::Testing.fake!
1596+
t = create_team
1597+
t.set_languages ['en', 'es']
1598+
t.save!
1599+
u = create_user
1600+
create_team_user team: t, user: u, role: 'admin'
1601+
pm = create_project_media team: t
1602+
fc = create_fact_check title: 'Foo Test', user: u, publisher_id: u.id, tags: ['test'], claim_description: create_claim_description(project_media: create_project_media(team: t, media: Blank.create!)), language: 'en'
1603+
fc.updated_at = Time.now + 1
1604+
fc.save!
1605+
sleep 1
1606+
ex = create_explainer team: t, title: 'Bar Test', user: u, tags: ['test'], language: 'es'
1607+
ex.updated_at = Time.now + 1
1608+
ex.save!
1609+
create_explainer team: t
1610+
create_explainer team: t
1611+
create_explainer
1612+
1613+
# Ensure we test for all filters
1614+
filters = {
1615+
user_ids: [u.id],
1616+
tags: ['test'],
1617+
language: ['en', 'es'],
1618+
created_at: { 'start_time' => Time.now.yesterday.to_datetime.to_s, 'end_time' => Time.now.tomorrow.to_datetime.to_s }.to_json,
1619+
updated_at: { 'start_time' => Time.now.yesterday.to_datetime.to_s, 'end_time' => Time.now.tomorrow.to_datetime.to_s }.to_json,
1620+
text: 'Test',
1621+
standalone: true,
1622+
publisher_ids: [u.id],
1623+
report_status: ['unpublished'],
1624+
rating: ['undetermined'],
1625+
imported: false,
1626+
target_id: pm.id,
1627+
trashed: false
1628+
}
1629+
assert_equal 2, t.filtered_articles(filters).count
1630+
1631+
# Ensure we test pagination
1632+
assert_equal ['Foo Test'], t.filtered_articles(filters, 1, 0, 'created_at', 'ASC').map(&:title)
1633+
assert_equal ['Bar Test'], t.filtered_articles(filters, 1, 1, 'created_at', 'ASC').map(&:title)
1634+
1635+
# Ensure we test for all sorting options
1636+
# Sort by updated date
1637+
assert_equal ['Foo Test'], t.filtered_articles(filters, 1, 0, 'updated_at', 'ASC').map(&:title)
1638+
assert_equal ['Bar Test'], t.filtered_articles(filters, 1, 0, 'updated_at', 'DESC').map(&:title)
1639+
# Sort by language
1640+
assert_equal ['Foo Test'], t.filtered_articles(filters, 1, 0, 'language', 'ASC').map(&:title)
1641+
assert_equal ['Bar Test'], t.filtered_articles(filters, 1, 0, 'language', 'DESC').map(&:title)
1642+
# Sort by title
1643+
assert_equal ['Bar Test'], t.filtered_articles(filters, 1, 0, 'title', 'ASC').map(&:title)
1644+
assert_equal ['Foo Test'], t.filtered_articles(filters, 1, 0, 'title', 'DESC').map(&:title)
1645+
1646+
# Ensure we don't have a N + 1 queries problem
1647+
# 4 queries: 1 for the target item, 1 for the articles, 1 for all fact-checks and 1 for all explainers
1648+
assert_queries 4, '=' do
1649+
t.filtered_articles
1650+
end
1651+
end
1652+
15941653
test "should return platforms for which statistics are available" do
15951654
t = create_team
15961655
pm = create_project_media team: t

0 commit comments

Comments
 (0)