Commit 9c0ef184 authored by Adam Hegyi's avatar Adam Hegyi

Merge branch '245323-arel-support-for-materialized-cte' into 'master'

Support AS MATERIALIZED in PG12 [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!56976
parents abb3dcb9 04c11221
......@@ -62,7 +62,7 @@ class IssueRebalancingService
def run_update_query(values, query_name)
Issue.connection.exec_query(<<~SQL, query_name)
WITH cte(cte_id, new_pos) AS (
WITH cte(cte_id, new_pos) AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
SELECT *
FROM (VALUES #{values}) as t (id, pos)
)
......
---
title: Add support for the MATERIALIZED keyword when using WITH (CTE) queries in PostgreSQL 12
merge_request: 56976
author:
type: other
# frozen_string_literal: true
# This patch adds support for AS MATERIALIZED in Arel, see Gitlab::Database::AsWithMaterialized for more info
module Arel
module Visitors
class Arel::Visitors::PostgreSQL
def visit_Gitlab_Database_AsWithMaterialized(obj, collector) # rubocop:disable Naming/MethodName
collector = visit obj.left, collector
collector << " AS#{obj.expr} "
visit obj.right, collector
end
end
end
end
......@@ -121,6 +121,8 @@ module ActiveRecord
end
when Arel::Nodes::As
with_value
when Gitlab::Database::AsWithMaterialized
with_value
end
end
......
......@@ -8,7 +8,7 @@ class AddIncidentSettingsToAllExistingProjects < ActiveRecord::Migration[6.0]
# to preserve behavior for existing projects that
# are using the create issue functionality with the default setting of true
query = <<-SQL
WITH project_ids AS (
WITH project_ids AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}(
SELECT DISTINCT issues.project_id AS id
FROM issues
LEFT OUTER JOIN project_incident_management_settings
......
......@@ -7,7 +7,7 @@ class SetReportTypeForVulnerabilities < ActiveRecord::Migration[5.2]
# set report_type based on vulnerability_occurrences from which the vulnerabilities were promoted,
# that is, first vulnerability_occurrences among those having the same vulnerability_id
execute <<~SQL
WITH first_findings_for_vulnerabilities AS (
WITH first_findings_for_vulnerabilities AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
SELECT MIN(id) AS id, vulnerability_id
FROM vulnerability_occurrences
WHERE vulnerability_id IS NOT NULL
......
......@@ -6,7 +6,7 @@ class SetResolvedStateOnVulnerabilities < ActiveRecord::Migration[5.2]
def up
execute <<~SQL
-- selecting IDs for all non-orphan Findings that either have no feedback or it's a non-dismissal feedback
WITH resolved_vulnerability_ids AS (
WITH resolved_vulnerability_ids AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
SELECT DISTINCT vulnerability_id AS id
FROM vulnerability_occurrences
LEFT JOIN vulnerability_feedback ON vulnerability_feedback.project_fingerprint = ENCODE(vulnerability_occurrences.project_fingerprint::bytea, 'HEX')
......
......@@ -55,7 +55,7 @@ class RemoveDuplicateLabelsFromProject < ActiveRecord::Migration[6.0]
# project_id title template description type color
duplicate_labels = ApplicationRecord.connection.execute(<<-SQL.squish)
WITH data AS (
WITH data AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
SELECT labels.*,
row_number() OVER (PARTITION BY labels.project_id, labels.title, labels.template, labels.description, labels.type, labels.color ORDER BY labels.id) AS row_number,
#{CREATE} AS restore_action
......@@ -83,7 +83,7 @@ WITH data AS (
# then add `_duplicate#{ID}`
soft_duplicates = ApplicationRecord.connection.execute(<<-SQL.squish)
WITH data AS (
WITH data AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
SELECT
*,
substring(title from 1 for 245 - length(id::text)) || '_duplicate' || id::text as new_title,
......@@ -108,7 +108,7 @@ WHERE labels.id IN (#{soft_duplicates.map { |dup| dup["id"] }.join(", ")});
def restore_renamed_labels(start_id, stop_id)
# the backup label IDs are not incremental, they are copied directly from the Labels table
ApplicationRecord.connection.execute(<<-SQL.squish)
WITH backups AS (
WITH backups AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
SELECT id, title
FROM backup_labels
WHERE project_id BETWEEN #{start_id} AND #{stop_id} AND
......
......@@ -59,7 +59,7 @@ class RemoveDuplicateLabelsFromGroup < ActiveRecord::Migration[6.0]
# group_id title template description type color
duplicate_labels = ApplicationRecord.connection.execute(<<-SQL.squish)
WITH data AS (
WITH data AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
SELECT labels.*,
row_number() OVER (PARTITION BY labels.group_id, labels.title, labels.template, labels.description, labels.type, labels.color ORDER BY labels.id) AS row_number,
#{CREATE} AS restore_action
......@@ -87,7 +87,7 @@ WITH data AS (
# then add `_duplicate#{ID}`
soft_duplicates = ApplicationRecord.connection.execute(<<-SQL.squish)
WITH data AS (
WITH data AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
SELECT
*,
substring(title from 1 for 245 - length(id::text)) || '_duplicate' || id::text as new_title,
......@@ -112,7 +112,7 @@ WHERE labels.id IN (#{soft_duplicates.map { |dup| dup["id"] }.join(", ")});
def restore_renamed_labels(start_id, stop_id)
# the backup label IDs are not incremental, they are copied directly from the Labels table
ApplicationRecord.connection.execute(<<-SQL.squish)
WITH backups AS (
WITH backups AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
SELECT id, title
FROM backup_labels
WHERE id BETWEEN #{start_id} AND #{stop_id}
......
......@@ -26,7 +26,7 @@ class MigrateLicenseManagementArtifactsToLicenseScanning < ActiveRecord::Migrati
min, max = relation.pluck('MIN(job_id)', 'MAX(job_id)').flatten
ActiveRecord::Base.connection.execute <<~SQL
WITH ci_job_artifacts_with_row_number as (
WITH ci_job_artifacts_with_row_number as #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
SELECT job_id, id, ROW_NUMBER() OVER (PARTITION BY job_id ORDER BY id ASC) as row_number
FROM ci_job_artifacts
WHERE (file_type = #{LICENSE_SCANNING_FILE_TYPE} OR file_type = #{LICENSE_MANAGEMENT_FILE_TYPE})
......
......@@ -32,7 +32,7 @@ class EnsureTargetProjectIdIsFilled < ActiveRecord::Migration[6.0]
)
MergeRequestMetrics.connection.execute <<-SQL
WITH target_project_id_and_metrics_id as (
WITH target_project_id_and_metrics_id as #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
#{query_for_cte.to_sql}
)
UPDATE #{MergeRequestMetrics.connection.quote_table_name(MergeRequestMetrics.table_name)}
......
......@@ -45,7 +45,7 @@ class CleanupProjectsWithBadHasExternalWikiData < ActiveRecord::Migration[6.0]
.merge(Project.where(has_external_wiki: false).where(pending_delete: false).where(archived: false))
execute(<<~SQL)
WITH project_ids_to_update (id) AS (
WITH project_ids_to_update (id) AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
#{scope_with_projects.to_sql}
)
UPDATE projects SET has_external_wiki = true WHERE id IN (SELECT id FROM project_ids_to_update)
......@@ -75,7 +75,7 @@ class CleanupProjectsWithBadHasExternalWikiData < ActiveRecord::Migration[6.0]
Project.where(index_where).each_batch(of: BATCH_SIZE) do |relation|
relation_with_exists_query = relation.where('NOT EXISTS (?)', services_sub_query)
execute(<<~SQL)
WITH project_ids_to_update (id) AS (
WITH project_ids_to_update (id) AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
#{relation_with_exists_query.select(:id).to_sql}
)
UPDATE projects SET has_external_wiki = false WHERE id IN (SELECT id FROM project_ids_to_update)
......
......@@ -44,7 +44,7 @@ class CleanupProjectsWithBadHasExternalIssueTrackerData < ActiveRecord::Migratio
.merge(Project.where(has_external_issue_tracker: false).where(pending_delete: false))
execute(<<~SQL)
WITH project_ids_to_update (id) AS (
WITH project_ids_to_update (id) AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
#{scope_with_projects.to_sql}
)
UPDATE projects SET has_external_issue_tracker = true WHERE id IN (SELECT id FROM project_ids_to_update)
......@@ -71,7 +71,7 @@ class CleanupProjectsWithBadHasExternalIssueTrackerData < ActiveRecord::Migratio
Project.where(index_where).each_batch(of: BATCH_SIZE) do |relation|
relation_with_exists_query = relation.where('NOT EXISTS (?)', services_sub_query)
execute(<<~SQL)
WITH project_ids_to_update (id) AS (
WITH project_ids_to_update (id) AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
#{relation_with_exists_query.select(:id).to_sql}
)
UPDATE projects SET has_external_issue_tracker = false WHERE id IN (SELECT id FROM project_ids_to_update)
......
......@@ -7,7 +7,7 @@ class RemoveDuplicatesFromProjectRegistry < ActiveRecord::Migration[4.2]
def up
execute <<-SQL
WITH good_rows AS (
WITH good_rows AS #{::Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
SELECT project_id, MAX(id) as max_id
FROM project_registry
GROUP BY project_id
......
......@@ -11,14 +11,14 @@ module EE
override :perform
def perform(note_id)
ActiveRecord::Base.connection.execute <<~SQL
WITH promotion_notes AS (
WITH promotion_notes AS #{::Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
SELECT noteable_id, note as promotion_note, projects.namespace_id as epic_group_id FROM notes
INNER JOIN projects ON notes.project_id = projects.id
WHERE notes.noteable_type = 'Issue'
AND notes.system IS TRUE
AND notes.note like 'promoted to epic%'
AND notes.id = #{Integer(note_id)}
), promoted_epics AS (
), promoted_epics AS #{::Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
SELECT epics.id as promoted_epic_id, promotion_notes.noteable_id as issue_id FROM epics
INNER JOIN promotion_notes on epics.group_id = promotion_notes.epic_group_id
WHERE concat('promoted to epic &', epics.iid) = promotion_notes.promotion_note
......
......@@ -97,13 +97,13 @@ module Gitlab
ActiveRecord::Base.connection.execute <<~SQL
WITH
starting_iids(project_id, iid) as (
starting_iids(project_id, iid) as #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}(
SELECT project_id, MAX(COALESCE(iid, 0))
FROM #{table}
WHERE project_id BETWEEN #{start_id} AND #{end_id}
GROUP BY project_id
),
with_calculated_iid(id, iid) as (
with_calculated_iid(id, iid) as #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}(
SELECT design.id,
init.iid + ROW_NUMBER() OVER (PARTITION BY design.project_id ORDER BY design.id ASC)
FROM #{table} as design, starting_iids as init
......
......@@ -8,7 +8,7 @@ module Gitlab
updated_repository_storages = Projects::RepositoryStorageMove.select("project_id, MAX(updated_at) as updated_at").where(project_id: project_ids).group(:project_id)
Project.connection.execute <<-SQL
WITH repository_storage_cte as (
WITH repository_storage_cte as #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
#{updated_repository_storages.to_sql}
)
UPDATE projects
......
......@@ -8,7 +8,7 @@ module Gitlab
def perform(start_id, stop_id)
ActiveRecord::Base.connection.execute <<~SQL
WITH merge_requests_batch AS (
WITH merge_requests_batch AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
SELECT id, target_project_id
FROM merge_requests WHERE id BETWEEN #{Integer(start_id)} AND #{Integer(stop_id)}
)
......
......@@ -22,7 +22,7 @@ module Gitlab
def sql(from_id, to_id)
<<~SQL
WITH created_records AS (
WITH created_records AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
INSERT INTO project_features (
project_id,
merge_requests_access_level,
......
......@@ -136,7 +136,7 @@ module Gitlab
# there is no uniq constraint on project_id and type pair, which prevents us from using ON CONFLICT
def create_sql(from_id, to_id)
<<~SQL
WITH created_records AS (
WITH created_records AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
INSERT INTO services (project_id, #{DEFAULTS.keys.map { |key| %("#{key}")}.join(',')}, created_at, updated_at)
#{select_insert_values_sql(from_id, to_id)}
RETURNING *
......@@ -149,7 +149,7 @@ module Gitlab
# there is no uniq constraint on project_id and type pair, which prevents us from using ON CONFLICT
def update_sql(from_id, to_id)
<<~SQL
WITH updated_records AS (
WITH updated_records AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
UPDATE services SET active = TRUE
WHERE services.project_id BETWEEN #{Integer(from_id)} AND #{Integer(to_id)} AND services.properties = '{}' AND services.type = '#{Migratable::PrometheusService.type}'
AND #{group_cluster_condition(from_id, to_id)} AND services.active = FALSE
......
......@@ -14,7 +14,7 @@ module Gitlab
def fix_namespace_names(from_id, to_id)
ActiveRecord::Base.connection.execute <<~UPDATE_NAMESPACES
WITH namespaces_to_update AS (
WITH namespaces_to_update AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
SELECT
namespaces.id,
users.name AS correct_name
......@@ -39,7 +39,7 @@ module Gitlab
def fix_namespace_route_names(from_id, to_id)
ActiveRecord::Base.connection.execute <<~ROUTES_UPDATE
WITH routes_to_update AS (
WITH routes_to_update AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
SELECT
routes.id,
users.name AS correct_name
......
......@@ -8,7 +8,7 @@ module Gitlab
class FixUserProjectRouteNames
def perform(from_id, to_id)
ActiveRecord::Base.connection.execute <<~ROUTES_UPDATE
WITH routes_to_update AS (
WITH routes_to_update AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
SELECT
routes.id,
users.name || ' / ' || projects.name AS correct_name
......
......@@ -8,8 +8,13 @@ module Gitlab
class ProjectSetting < ActiveRecord::Base # rubocop:disable Style/Documentation
self.table_name = 'project_settings'
UPSERT_SQL = <<~SQL
WITH upsert_data (project_id, has_vulnerabilities, created_at, updated_at) AS (
def self.upsert_for(project_ids)
connection.execute(upsert_sql % { project_ids: project_ids.join(', ') })
end
def self.upsert_sql
<<~SQL
WITH upsert_data (project_id, has_vulnerabilities, created_at, updated_at) AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
SELECT projects.id, true, current_timestamp, current_timestamp FROM projects WHERE projects.id IN (%{project_ids})
)
INSERT INTO project_settings
......@@ -20,9 +25,6 @@ module Gitlab
has_vulnerabilities = true,
updated_at = EXCLUDED.updated_at
SQL
def self.upsert_for(project_ids)
connection.execute(UPSERT_SQL % { project_ids: project_ids.join(', ') })
end
end
......
......@@ -57,7 +57,7 @@ module Gitlab
def update_email_records(start_id, stop_id)
EmailModel.connection.execute <<-SQL
WITH md5_strings as (
WITH md5_strings as #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
#{email_query_for_update(start_id, stop_id).to_sql}
)
UPDATE #{EmailModel.connection.quote_table_name(EmailModel.table_name)}
......
# frozen_string_literal: true
module Gitlab
module Database
# This class is a special Arel node which allows optionally define the `MATERIALIZED` keyword for CTE and Recursive CTE queries.
class AsWithMaterialized < Arel::Nodes::Binary
extend Gitlab::Utils::StrongMemoize
MATERIALIZED = Arel.sql(' MATERIALIZED')
EMPTY_STRING = Arel.sql('')
attr_reader :expr
def initialize(left, right, materialized: true)
@expr = if materialized && self.class.materialized_supported?
MATERIALIZED
else
EMPTY_STRING
end
super(left, right)
end
# Note: to be deleted after the minimum PG version is set to 12.0
def self.materialized_supported?
strong_memoize(:materialized_supported) do
Gitlab::Database.version.match?(/^1[2-9]\./) # version 12.x and above
end
end
# Note: to be deleted after the minimum PG version is set to 12.0
def self.materialized_if_supported
materialized_supported? ? 'MATERIALIZED' : ''
end
end
end
end
......@@ -130,7 +130,7 @@ module Gitlab
def sql
<<~SQL
WITH cte(#{list_of(cte_columns)}) AS (VALUES #{list_of(values)})
WITH cte(#{list_of(cte_columns)}) AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (VALUES #{list_of(values)})
UPDATE #{table_name} SET #{list_of(updates)} FROM cte WHERE cte_id = id
SQL
end
......
......@@ -41,19 +41,6 @@ module Gitlab
BUCKET_ID_MASK = (Buckets::TOTAL_BUCKETS - ZERO_OFFSET).to_s(2)
BIT_31_MASK = "B'0#{'1' * 31}'"
BIT_32_NORMALIZED_BUCKET_ID_MASK = "B'#{'0' * (32 - BUCKET_ID_MASK.size)}#{BUCKET_ID_MASK}'"
# @example source_query
# SELECT CAST(('X' || md5(CAST(%{column} as text))) as bit(32)) attr_hash_32_bits
# FROM %{relation}
# WHERE %{pkey} >= %{batch_start}
# AND %{pkey} < %{batch_end}
# AND %{column} IS NOT NULL
BUCKETED_DATA_SQL = <<~SQL
WITH hashed_attributes AS (%{source_query})
SELECT (attr_hash_32_bits & #{BIT_32_NORMALIZED_BUCKET_ID_MASK})::int AS bucket_num,
(31 - floor(log(2, min((attr_hash_32_bits & #{BIT_31_MASK})::int))))::int as bucket_hash
FROM hashed_attributes
GROUP BY 1
SQL
WRONG_CONFIGURATION_ERROR = Class.new(ActiveRecord::StatementInvalid)
......@@ -103,7 +90,7 @@ module Gitlab
def hll_buckets_for_batch(start, finish)
@relation
.connection
.execute(BUCKETED_DATA_SQL % { source_query: source_query(start, finish) })
.execute(bucketed_data_sql % { source_query: source_query(start, finish) })
.map(&:values)
.to_h
end
......@@ -139,6 +126,22 @@ module Gitlab
def actual_finish(finish)
finish || @relation.unscope(:group, :having).maximum(@relation.primary_key) || 0
end
# @example source_query
# SELECT CAST(('X' || md5(CAST(%{column} as text))) as bit(32)) attr_hash_32_bits
# FROM %{relation}
# WHERE %{pkey} >= %{batch_start}
# AND %{pkey} < %{batch_end}
# AND %{column} IS NOT NULL
def bucketed_data_sql
<<~SQL
WITH hashed_attributes AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (%{source_query})
SELECT (attr_hash_32_bits & #{BIT_32_NORMALIZED_BUCKET_ID_MASK})::int AS bucket_num,
(31 - floor(log(2, min((attr_hash_32_bits & #{BIT_31_MASK})::int))))::int as bucket_hash
FROM hashed_attributes
GROUP BY 1
SQL
end
end
end
end
......
......@@ -15,20 +15,27 @@ module Gitlab
# Namespace
# with(cte.to_arel).
# from(cte.alias_to(ns))
#
# To skip materialization of the CTE query by passing materialized: false
# More context: https://www.postgresql.org/docs/12/queries-with.html
#
# cte = CTE.new(:my_cte_name, materialized: false)
#
class CTE
attr_reader :table, :query
# name - The name of the CTE as a String or Symbol.
def initialize(name, query)
def initialize(name, query, materialized: true)
@table = Arel::Table.new(name)
@query = query
@materialized = materialized
end
# Returns the Arel relation for this CTE.
def to_arel
sql = Arel::Nodes::SqlLiteral.new("(#{query.to_sql})")
Arel::Nodes::As.new(table, sql)
Gitlab::Database::AsWithMaterialized.new(table, sql, materialized: @materialized)
end
# Returns an "AS" statement that aliases the CTE name as the given table
......
......@@ -41,4 +41,15 @@ RSpec.describe Gitlab::SQL::CTE do
expect(relation.to_a).to eq(User.where(id: user.id).to_a)
end
end
it_behaves_like 'CTE with MATERIALIZED keyword examples' do
let(:expected_query_block_with_materialized) { 'WITH "some_cte" AS MATERIALIZED (' }
let(:expected_query_block_without_materialized) { 'WITH "some_cte" AS (' }
let(:query) do
cte = described_class.new(:some_cte, User.active, **options)
User.with(cte.to_arel).to_sql
end
end
end
......@@ -57,4 +57,17 @@ RSpec.describe Gitlab::SQL::RecursiveCTE do
expect(relation.to_a).to eq(User.where(id: user.id).to_a)
end
end
it_behaves_like 'CTE with MATERIALIZED keyword examples' do
# MATERIALIZED keyword is not needed for recursive queries
let(:expected_query_block_with_materialized) { 'WITH RECURSIVE "some_cte" AS (' }
let(:expected_query_block_without_materialized) { 'WITH RECURSIVE "some_cte" AS (' }
let(:query) do
recursive_cte = described_class.new(:some_cte)
recursive_cte << User.active
User.with.recursive(recursive_cte.to_arel).to_sql
end
end
end
......@@ -4109,7 +4109,7 @@ RSpec.describe Project, factory_default: :keep do
subject { described_class.wrap_with_cte(projects) }
it 'wrapped query matches original' do
expect(subject.to_sql).to match(/^WITH "projects_cte" AS/)
expect(subject.to_sql).to match(/^WITH "projects_cte" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/)
expect(subject).to match_array(projects)
end
end
......
# frozen_string_literal: true
RSpec.shared_examples 'CTE with MATERIALIZED keyword examples' do
describe 'adding MATERIALIZE to the CTE' do
let(:options) { {} }
before do
# Clear the cached value before the test
Gitlab::Database::AsWithMaterialized.clear_memoization(:materialized_supported)
end
context 'when PG version is <12' do
it 'does not add MATERIALIZE keyword' do
allow(Gitlab::Database).to receive(:version).and_return('11.1')
expect(query).to include(expected_query_block_without_materialized)
end
end
context 'when PG version is >=12' do
it 'adds MATERIALIZE keyword' do
allow(Gitlab::Database).to receive(:version).and_return('12.1')
expect(query).to include(expected_query_block_with_materialized)
end
context 'when version is higher than 12' do
it 'adds MATERIALIZE keyword' do
allow(Gitlab::Database).to receive(:version).and_return('15.1')
expect(query).to include(expected_query_block_with_materialized)
end
end
context 'when materialized is disabled' do
let(:options) { { materialized: false } }
it 'does not add MATERIALIZE keyword' do
expect(query).to include(expected_query_block_without_materialized)
end
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment