Commit 874daf71 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents d7096bd9 7a3a4c0b
......@@ -30,6 +30,7 @@ class CopyCodeButton extends HTMLElement {
function addCodeButton() {
[...document.querySelectorAll('pre.code.js-syntax-highlight')]
.filter((el) => el.attr('lang') !== 'mermaid')
.filter((el) => !el.closest('.js-markdown-code'))
.forEach((el) => {
const copyCodeEl = document.createElement('copy-code');
......
......@@ -23,4 +23,39 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController
def feature_flag_enabled!
access_denied! unless Feature.enabled?(:incubation_5mp_google_cloud, project)
end
def validate_gcp_token!
is_token_valid = GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
.validate_token(expires_at_in_session)
return if is_token_valid
return_url = project_google_cloud_index_path(project)
state = generate_session_key_redirect(request.url, return_url)
@authorize_url = GoogleApi::CloudPlatform::Client.new(nil,
callback_google_api_auth_url,
state: state).authorize_url
redirect_to @authorize_url
end
def generate_session_key_redirect(uri, error_uri)
GoogleApi::CloudPlatform::Client.new_session_key_for_redirect_uri do |key|
session[key] = uri
session[:error_uri] = error_uri
end
end
def token_in_session
session[GoogleApi::CloudPlatform::Client.session_key_for_token]
end
def expires_at_in_session
session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at]
end
def handle_gcp_error(error, project)
Gitlab::ErrorTracking.track_exception(error, project_id: project.id)
@js_data = { screen: 'gcp_error', error: error.to_s }.to_json
render status: :unauthorized, template: 'projects/google_cloud/errors/gcp_error'
end
end
# frozen_string_literal: true
class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::BaseController
before_action :validate_gcp_token!
def cloud_run
render json: "Placeholder"
end
def cloud_storage
render json: "Placeholder"
end
end
......@@ -45,41 +45,4 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud::
rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error
handle_gcp_error(error, project)
end
private
def validate_gcp_token!
is_token_valid = GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
.validate_token(expires_at_in_session)
return if is_token_valid
return_url = project_google_cloud_index_path(project)
state = generate_session_key_redirect(request.url, return_url)
@authorize_url = GoogleApi::CloudPlatform::Client.new(nil,
callback_google_api_auth_url,
state: state).authorize_url
redirect_to @authorize_url
end
def generate_session_key_redirect(uri, error_uri)
GoogleApi::CloudPlatform::Client.new_session_key_for_redirect_uri do |key|
session[key] = uri
session[:error_uri] = error_uri
end
end
def token_in_session
session[GoogleApi::CloudPlatform::Client.session_key_for_token]
end
def expires_at_in_session
session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at]
end
def handle_gcp_error(error, project)
Gitlab::ErrorTracking.track_exception(error, project_id: project.id)
@js_data = { screen: 'gcp_error', error: error.to_s }.to_json
render status: :unauthorized, template: 'projects/google_cloud/errors/gcp_error'
end
end
......@@ -19,6 +19,7 @@ class Projects::TreeController < Projects::ApplicationController
push_frontend_feature_flag(:lazy_load_commits, @project, default_enabled: :yaml)
push_frontend_feature_flag(:new_dir_modal, @project, default_enabled: :yaml)
push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml)
push_frontend_feature_flag(:highlight_js, @project, default_enabled: :yaml)
end
feature_category :source_code_management
......
......@@ -4,6 +4,7 @@ module Types
module Ci
class RunnerType < BaseObject
edge_type_class(RunnerWebUrlEdge)
connection_type_class(Types::CountableConnectionType)
graphql_name 'CiRunner'
authorize :read_runner
present_using ::Ci::RunnerPresenter
......
......@@ -427,6 +427,10 @@ module Ci
action? && !archived? && (manual? || scheduled? || retryable?)
end
def waiting_for_deployment_approval?
manual? && starts_environment? && deployment&.blocked?
end
def schedulable?
self.when == 'delayed' && options[:start_in].present?
end
......
......@@ -123,8 +123,6 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :read_group_member
enable :read_custom_emoji
enable :read_counts
enable :read_crm_organization
enable :read_crm_contact
end
rule { ~public_group & ~has_access }.prevent :read_counts
......@@ -159,6 +157,8 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :read_prometheus
enable :read_package
enable :read_package_settings
enable :read_crm_organization
enable :read_crm_contact
end
rule { maintainer }.policy do
......
......@@ -28,18 +28,16 @@ module Ci
return if events.empty?
first = events.first
last_processed = nil
processed_events = []
begin
events.each do |event|
@sync_class.sync!(event)
last_processed = event
processed_events << event
end
ensure
# remove events till the one that was last succesfully processed
@sync_event_class.id_in(first.id..last_processed.id).delete_all if last_processed
@sync_event_class.id_in(processed_events).delete_all
end
end
......
......@@ -319,6 +319,9 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
namespace :google_cloud do
resources :service_accounts, only: [:index, :create]
get '/deployments/cloud_run', to: 'deployments#cloud_run'
get '/deployments/cloud_storage', to: 'deployments#cloud_storage'
end
resources :environments, except: [:destroy] do
......
# frozen_string_literal: true
class AddCiRunnersIndexOnActiveState < Gitlab::Database::Migration[1.0]
disable_ddl_transaction!
INDEX_NAME = 'index_ci_runners_on_active'
def up
add_concurrent_index :ci_runners, [:active, :id], name: INDEX_NAME
end
def down
remove_concurrent_index_by_name :ci_runners, INDEX_NAME
end
end
5e5e41ee4c8dc9c3fe791470862d15b8d213fcc931ef8b80937bdb5f5db20aed
\ No newline at end of file
......@@ -25593,6 +25593,8 @@ CREATE INDEX index_ci_runner_projects_on_project_id ON ci_runner_projects USING
CREATE INDEX index_ci_runner_projects_on_runner_id ON ci_runner_projects USING btree (runner_id);
CREATE INDEX index_ci_runners_on_active ON ci_runners USING btree (active, id);
CREATE INDEX index_ci_runners_on_contacted_at_and_id_desc ON ci_runners USING btree (contacted_at, id DESC);
CREATE INDEX index_ci_runners_on_contacted_at_and_id_where_inactive ON ci_runners USING btree (contacted_at DESC, id DESC) WHERE (active = false);
......@@ -5503,6 +5503,7 @@ The connection type for [`CiRunner`](#cirunner).
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="cirunnerconnectioncount"></a>`count` | [`Int!`](#int) | Total count of collection. |
| <a id="cirunnerconnectionedges"></a>`edges` | [`[CiRunnerEdge]`](#cirunneredge) | A list of edges. |
| <a id="cirunnerconnectionnodes"></a>`nodes` | [`[CiRunner]`](#cirunner) | A list of nodes. |
| <a id="cirunnerconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
......@@ -589,6 +589,87 @@ LIMIT 20
NOTE:
To make the query efficient, the following columns need to be covered with an index: `project_id`, `issue_type`, `created_at`, and `id`.
#### Using calculated ORDER BY expression
The following example orders epic records by the duration between the creation time and closed
time. It is calculated with the following formula:
```sql
SELECT EXTRACT('epoch' FROM epics.closed_at - epics.created_at) FROM epics
```
The query above returns the duration in seconds (`double precision`) between the two timestamp
columns in seconds. To order the records by this expression, you must reference it
in the `ORDER BY` clause:
```sql
SELECT EXTRACT('epoch' FROM epics.closed_at - epics.created_at)
FROM epics
ORDER BY EXTRACT('epoch' FROM epics.closed_at - epics.created_at) DESC
```
To make this ordering efficient on the group-level with the in-operator optimization, use a
custom `ORDER BY` configuration. Since the duration is not a distinct value (no unique index
present), you must add a tie-breaker column (`id`).
The following example shows the final `ORDER BY` clause:
```sql
ORDER BY extract('epoch' FROM epics.closed_at - epics.created_at) DESC, epics.id DESC
```
Snippet for loading records ordered by the calcualted duration:
```ruby
arel_table = Epic.arel_table
order = Gitlab::Pagination::Keyset::Order.build([
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'duration_in_seconds',
order_expression: Arel.sql('EXTRACT(EPOCH FROM epics.closed_at - epics.created_at)').desc,
distinct: false,
sql_type: 'double precision' # important for calculated SQL expressions
),
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'id',
order_expression: arel_table[:id].desc
)
])
records = Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder.new(
scope: Epic.where.not(closed_at: nil).reorder(order), # filter out NULL values
array_scope: Group.find(9970).self_and_descendants.select(:id),
array_mapping_scope: -> (id_expression) { Epic.where(Epic.arel_table[:group_id].eq(id_expression)) }
).execute.limit(20)
puts records.pluck(:duration_in_seconds, :id) # other columnns are not available
```
Building the query requires quite a bit of configuration. For the order configuration you
can find more information within the
[complex order configuration](keyset_pagination.md#complex-order-configuration)
section for keyset paginated database queries.
The query requires a specialized database index:
```sql
CREATE INDEX index_epics_on_duration ON epics USING btree (group_id, EXTRACT(EPOCH FROM epics.closed_at - epics.created_at) DESC, id DESC) WHERE (closed_at IS NOT NULL);
```
Notice that the `finder_query` parameter is not used. The query only returns the `ORDER BY` columns
which are the `duration_in_seconds` (calculated column) and the `id` columns. This is a limitation
of the feature, defining the `finder_query` with calculated `ORDER BY` expressions is not supported.
To get the complete database records, an extra query can be invoked by the returned `id` column:
```ruby
records_by_id = records.index_by(&:id)
complete_records = Epic.where(id: records_by_id.keys).index_by(&:id)
# Printing the complete records according to the `ORDER BY` clause
records_by_id.each do |id, _|
puts complete_records[id].attributes
end
```
#### Batch iteration
Batch iteration over the records is possible via the keyset `Iterator` class.
......
......@@ -14,6 +14,15 @@ With customer relations management (CRM) you can create a record of contacts
You can use contacts and organizations to tie work to customers for billing and reporting purposes.
To read more about what is planned for the future, see [issue 2256](https://gitlab.com/gitlab-org/gitlab/-/issues/2256).
## Permissions
| Permission | Guest | Reporter | Developer, Maintainer, and Owner |
| ---------- | ---------------- | -------- | -------------------------------- |
| View contacts/organizations | | ✓ | ✓ |
| View issue contacts | | ✓ | ✓ |
| Add/remove issue contacts | | ✓ | ✓ |
| Create/edit contacts/organizations | | | ✓ |
## Enable customer relations management (CRM)
To enable customer relations management in a group:
......@@ -122,10 +131,6 @@ API.
### Add or remove issue contacts
Prerequisites:
- You must have at least the [Developer role](../permissions.md#project-members-permissions) for a group.
### Add contacts to an issue
To add contacts to an issue use the `/add_contacts`
......
<script>
import emptySvg from '@gitlab/svgs/dist/illustrations/security-dashboard-empty-state.svg';
import { GlEmptyState, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { GlEmptyState, GlIcon, GlAlert, GlTooltipDirective } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import OrderSummary from 'ee/subscriptions/buy_addons_shared/components/order_summary.vue';
import { ERROR_FETCHING_DATA_HEADER, ERROR_FETCHING_DATA_DESCRIPTION } from '~/ensure_data';
......@@ -18,6 +18,7 @@ export default {
Checkout,
GlEmptyState,
GlIcon,
GlAlert,
OrderSummary,
},
directives: {
......@@ -36,6 +37,7 @@ export default {
data() {
return {
hasError: false,
alertMessage: '',
};
},
computed: {
......@@ -88,6 +90,9 @@ export default {
selectedPlanPrice: price,
});
},
alertError(errorMessage) {
this.alertMessage = errorMessage;
},
},
apollo: {
plans: {
......@@ -130,8 +135,11 @@ export default {
class="row gl-flex-grow-1 gl-flex-direction-column gl-flex-nowrap gl-lg-flex-direction-row gl-xl-flex-direction-row gl-lg-flex-wrap gl-xl-flex-wrap"
>
<div
class="checkout-pane gl-px-3 gl-align-items-center gl-bg-gray-10 col-lg-7 gl-display-flex gl-flex-direction-column gl-flex-grow-1"
class="checkout-pane gl-px-3 gl-pt-5 gl-align-items-center gl-bg-gray-10 col-lg-7 gl-display-flex gl-flex-direction-column gl-flex-grow-1"
>
<gl-alert v-if="alertMessage" class="checkout-alert" variant="danger" :dismissible="false">
{{ alertMessage }}
</gl-alert>
<checkout :plan="plan">
<template #purchase-details>
<addon-purchase-details
......@@ -162,6 +170,7 @@ export default {
:plan="plan"
:title="config.title"
:purchase-has-expiration="config.hasExpiration"
@alertError="alertError"
>
<template #price-per-unit="{ price }">
{{ pricePerUnitLabel(price) }}
......
......@@ -4,7 +4,7 @@ import find from 'lodash/find';
import { logError } from '~/lib/logger';
import { TAX_RATE } from 'ee/subscriptions/new/constants';
import { CUSTOMERSDOT_CLIENT } from 'ee/subscriptions/buy_addons_shared/constants';
import { CUSTOMERSDOT_CLIENT, I18N_API_ERROR } from 'ee/subscriptions/buy_addons_shared/constants';
import formattingMixins from 'ee/subscriptions/new/formatting_mixins';
import { sprintf } from '~/locale';
......@@ -62,13 +62,16 @@ export default {
},
manual: true,
result({ data }) {
if (data.orderPreview) {
if (data.errors) {
this.hasError = true;
} else if (data.orderPreview) {
this.endDate = data.orderPreview.targetDate;
this.proratedAmount = data.orderPreview.amount;
}
},
error(error) {
this.hasError = true;
this.$emit('alertError', I18N_API_ERROR);
logError(error);
},
skip() {
......@@ -91,7 +94,7 @@ export default {
return this.plan.pricePerYear;
},
totalExVat() {
return this.isLoading
return this.hideAmount
? 0
: this.proratedAmount || this.subscription.quantity * this.selectedPlanPrice;
},
......@@ -99,7 +102,7 @@ export default {
return TAX_RATE * this.totalExVat;
},
totalAmount() {
return this.isLoading ? 0 : this.proratedAmount || this.totalExVat + this.vat;
return this.hideAmount ? 0 : this.proratedAmount || this.totalExVat + this.vat;
},
quantityPresent() {
return this.subscription.quantity > 0;
......@@ -116,6 +119,9 @@ export default {
isLoading() {
return this.$apollo.loading;
},
hideAmount() {
return this.isLoading || this.hasError;
},
},
taxRate: TAX_RATE,
};
......
......@@ -63,3 +63,7 @@ export const I18N_SUMMARY_TAX_NOTE = s__(
'Checkout|(may be %{linkStart}charged upon purchase%{linkEnd})',
);
export const I18N_SUMMARY_TOTAL = s__('Checkout|Total');
export const I18N_API_ERROR = s__(
'Checkout|An unknown error has occurred. Please try again by refreshing this page.',
);
......@@ -3,7 +3,7 @@
class="row gl-flex-grow-1 gl-flex-direction-column gl-flex-nowrap gl-lg-flex-direction-row gl-xl-flex-direction-row gl-lg-flex-wrap gl-xl-flex-wrap"
>
<div
class="checkout-pane gl-px-3 gl-align-items-center gl-bg-gray-10 col-lg-7 gl-display-flex gl-flex-direction-column gl-flex-grow-1"
class="checkout-pane gl-px-3 gl-pt-5 gl-align-items-center gl-bg-gray-10 col-lg-7 gl-display-flex gl-flex-direction-column gl-flex-grow-1"
>
<slot name="checkout"></slot>
</div>
......
......@@ -20,15 +20,22 @@ $subscriptions-full-width-lg: 541px;
}
}
.checkout-alert {
width: 100%;
max-width: $subscriptions-full-width-md;
@media(min-width: map-get($grid-breakpoints, lg)) {
max-width: $subscriptions-full-width-lg;
}
}
.checkout {
align-items: center;
flex-grow: 1;
margin-top: $gl-padding;
max-width: $subscriptions-full-width-md;
@media(min-width: map-get($grid-breakpoints, lg)) {
justify-content: inherit !important;
margin-top: $gl-padding-32;
max-width: none;
}
......
import { GlEmptyState } from '@gitlab/ui';
import { GlEmptyState, GlAlert } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { pick } from 'lodash';
......@@ -13,6 +13,7 @@ import {
I18N_STORAGE_TITLE,
I18N_STORAGE_PRICE_PER_UNIT,
I18N_STORAGE_TOOLTIP_NOTE,
I18N_API_ERROR,
planTags,
STORAGE_PER_PACK,
} from 'ee/subscriptions/buy_addons_shared/constants';
......@@ -67,6 +68,7 @@ describe('Buy Storage App', () => {
const getStoragePlan = () => pick(mockStoragePlans[0], ['id', 'code', 'pricePerYear', 'name']);
const findCheckout = () => wrapper.findComponent(Checkout);
const findOrderSummary = () => wrapper.findComponent(OrderSummary);
const findAlert = () => wrapper.findComponent(GlAlert);
const findPriceLabel = () => wrapper.findByTestId('price-per-unit');
const findQuantityText = () => wrapper.findByTestId('addon-quantity-text');
const findRootElement = () => wrapper.findByTestId('buy-addons-shared');
......@@ -100,6 +102,20 @@ describe('Buy Storage App', () => {
title: I18N_STORAGE_TITLE,
});
});
describe('and an error occurred', () => {
beforeEach(() => {
findOrderSummary().vm.$emit('alertError', I18N_API_ERROR);
});
it('shows the alert', () => {
expect(findAlert().props()).toMatchObject({
dismissible: false,
variant: 'danger',
});
expect(findAlert().text()).toBe(I18N_API_ERROR);
});
});
});
describe('when data is not received', () => {
......
......@@ -15,7 +15,7 @@ import {
import createMockApollo, { createMockClient } from 'helpers/mock_apollo_helper';
import orderPreviewQuery from 'ee/subscriptions/graphql/queries/order_preview.customer.query.graphql';
import { CUSTOMERSDOT_CLIENT } from 'ee/subscriptions/buy_addons_shared/constants';
import { CUSTOMERSDOT_CLIENT, I18N_API_ERROR } from 'ee/subscriptions/buy_addons_shared/constants';
const localVue = createLocalVue();
localVue.use(VueApollo);
......@@ -123,7 +123,7 @@ describe('Order Summary', () => {
createComponent(apolloProvider, { purchaseHasExpiration: true });
});
it('renders amount from the state', () => {
it('renders default amount without proration from the state', () => {
expect(findAmount().text()).toBe('$60');
});
});
......@@ -139,8 +139,32 @@ describe('Order Summary', () => {
createComponent(apolloProvider, { purchaseHasExpiration: true });
});
it('renders amount from the state', () => {
expect(findAmount().text()).toBe('$60');
it('does not render amount', () => {
expect(findAmount().text()).toBe('-');
});
});
describe('calls api that returns an error', () => {
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
const orderPreviewQueryMock = jest.fn().mockRejectedValue(new Error('An error happened!'));
const apolloProvider = createMockApolloProvider(
{ subscription: { quantity: 1 } },
orderPreviewQueryMock,
);
createComponent(apolloProvider, { purchaseHasExpiration: true });
});
it('does not render amount', () => {
expect(findAmount().text()).toBe('-');
});
it('should emit `alertError` event', async () => {
jest.spyOn(wrapper.vm, '$emit');
await wrapper.vm.$nextTick();
expect(wrapper.vm.$emit).toHaveBeenCalledWith('alertError', I18N_API_ERROR);
});
});
......
......@@ -14,7 +14,8 @@ module Gitlab
Status::Build::WaitingForResource,
Status::Build::Preparing,
Status::Build::Pending,
Status::Build::Skipped],
Status::Build::Skipped,
Status::Build::WaitingForApproval],
[Status::Build::Cancelable,
Status::Build::Retryable],
[Status::Build::FailedUnmetPrerequisites,
......
# frozen_string_literal: true
module Gitlab
module Ci
module Status
module Build
class WaitingForApproval < Status::Extended
def illustration
{
image: 'illustrations/manual_action.svg',
size: 'svg-394',
title: 'Waiting for approval',
content: "This job deploys to the protected environment \"#{subject.deployment&.environment&.name}\" which requires approvals. Use the Deployments API to approve or reject the deployment."
}
end
def self.matches?(build, user)
build.waiting_for_deployment_approval?
end
end
end
end
end
end
......@@ -114,6 +114,20 @@ module Gitlab
# - When the order is a calculated expression or the column is in another table (JOIN-ed)
#
# If the add_to_projections is true, the query builder will automatically add the column to the SELECT values
#
# **sql_type**
#
# The SQL type of the column or SQL expression. This is an optional field which is only required when using the
# column with the InOperatorOptimization class.
#
# Example: When the order expression is a calculated SQL expression.
#
# {
# attribute_name: 'id_times_count',
# order_expression: Arel.sql('(id * count)').asc,
# sql_type: 'integer' # the SQL type here must match with the type of the produced data by the order_expression. Putting 'text' here would be incorrect.
# }
#
class ColumnOrderDefinition
REVERSED_ORDER_DIRECTIONS = { asc: :desc, desc: :asc }.freeze
REVERSED_NULL_POSITIONS = { nulls_first: :nulls_last, nulls_last: :nulls_first }.freeze
......@@ -122,7 +136,8 @@ module Gitlab
attr_reader :attribute_name, :column_expression, :order_expression, :add_to_projections, :order_direction
def initialize(attribute_name:, order_expression:, column_expression: nil, reversed_order_expression: nil, nullable: :not_nullable, distinct: true, order_direction: nil, add_to_projections: false)
# rubocop: disable Metrics/ParameterLists
def initialize(attribute_name:, order_expression:, column_expression: nil, reversed_order_expression: nil, nullable: :not_nullable, distinct: true, order_direction: nil, sql_type: nil, add_to_projections: false)
@attribute_name = attribute_name
@order_expression = order_expression
@column_expression = column_expression || calculate_column_expression(order_expression)
......@@ -130,8 +145,10 @@ module Gitlab
@reversed_order_expression = reversed_order_expression || calculate_reversed_order(order_expression)
@nullable = parse_nullable(nullable, distinct)
@order_direction = parse_order_direction(order_expression, order_direction)
@sql_type = sql_type
@add_to_projections = add_to_projections
end
# rubocop: enable Metrics/ParameterLists
def reverse
self.class.new(
......@@ -185,6 +202,12 @@ module Gitlab
sql_string
end
def sql_type
raise Gitlab::Pagination::Keyset::SqlTypeMissingError.for_column(self) if @sql_type.nil?
@sql_type
end
private
attr_reader :reversed_order_expression, :nullable, :distinct
......
......@@ -4,23 +4,35 @@ module Gitlab
module Pagination
module Keyset
module InOperatorOptimization
# This class is used for wrapping an Arel column with
# convenient helper methods in order to make the query
# building for the InOperatorOptimization a bit cleaner.
class ColumnData
attr_reader :original_column_name, :as, :arel_table
def initialize(original_column_name, as, arel_table)
@original_column_name = original_column_name.to_s
# column - name of the DB column
# as - custom alias for the column
# arel_table - relation where the column is located
def initialize(column, as, arel_table)
@original_column_name = column
@as = as.to_s
@arel_table = arel_table
end
# Generates: `issues.name AS my_alias`
def projection
arel_column.as(as)
end
# Generates: issues.name`
def arel_column
arel_table[original_column_name]
end
# overridden in OrderByColumnData class
alias_method :column_expression, :arel_column
# Generates: `issues.my_alias`
def arel_column_as
arel_table[as]
end
......@@ -29,8 +41,9 @@ module Gitlab
"#{arel_table.name}_#{original_column_name}_array"
end
# Generates: SELECT ARRAY_AGG(...) AS issues_name_array
def array_aggregated_column
Arel::Nodes::NamedFunction.new('ARRAY_AGG', [arel_column]).as(array_aggregated_column_name)
Arel::Nodes::NamedFunction.new('ARRAY_AGG', [column_expression]).as(array_aggregated_column_name)
end
end
end
......
# frozen_string_literal: true
module Gitlab
module Pagination
module Keyset
module InOperatorOptimization
class OrderByColumnData < ColumnData
extend ::Gitlab::Utils::Override
attr_reader :column
# column - a ColumnOrderDefinition object
# as - custom alias for the column
# arel_table - relation where the column is located
def initialize(column, as, arel_table)
super(column.attribute_name.to_s, as, arel_table)
@column = column
end
override :arel_column
def arel_column
column.column_expression
end
override :column_expression
def column_expression
arel_table[original_column_name]
end
def column_for_projection
column.column_expression.as(original_column_name)
end
end
end
end
end
end
......@@ -9,16 +9,16 @@ module Gitlab
# This class exposes collection methods for the order by columns
#
# Example: by modelling the `issues.created_at ASC, issues.id ASC` ORDER BY
# Example: by modeling the `issues.created_at ASC, issues.id ASC` ORDER BY
# SQL clause, this class will receive two ColumnOrderDefinition objects
def initialize(columns, arel_table)
@columns = columns.map do |column|
ColumnData.new(column.attribute_name, "order_by_columns_#{column.attribute_name}", arel_table)
OrderByColumnData.new(column, "order_by_columns_#{column.attribute_name}", arel_table)
end
end
def arel_columns
columns.map(&:arel_column)
columns.map(&:column_for_projection)
end
def array_aggregated_columns
......
......@@ -120,7 +120,7 @@ module Gitlab
.from(array_cte)
.join(Arel.sql("LEFT JOIN LATERAL (#{initial_keyset_query.to_sql}) #{table_name} ON TRUE"))
order_by_columns.each { |column| q.where(column.arel_column.not_eq(nil)) }
order_by_columns.each { |column| q.where(column.column_expression.not_eq(nil)) }
q.as('array_scope_lateral_query')
end
......@@ -231,7 +231,7 @@ module Gitlab
order
.apply_cursor_conditions(keyset_scope, cursor_values, use_union_optimization: true)
.reselect(*order_by_columns.arel_columns)
.reselect(*order_by_columns.map(&:column_for_projection))
.limit(1)
end
......
......@@ -12,11 +12,7 @@ module Gitlab
end
def initializer_columns
order_by_columns.map do |column|
column_name = column.original_column_name.to_s
type = model.columns_hash[column_name].sql_type
"NULL::#{type} AS #{column_name}"
end
order_by_columns.map { |column_data| null_with_type_cast(column_data) }
end
def columns
......@@ -30,6 +26,15 @@ module Gitlab
private
attr_reader :model, :order_by_columns
def null_with_type_cast(column_data)
column_name = column_data.original_column_name.to_s
active_record_column = model.columns_hash[column_name]
type = active_record_column ? active_record_column.sql_type : column_data.column.sql_type
"NULL::#{type} AS #{column_name}"
end
end
end
end
......
......@@ -9,6 +9,8 @@ module Gitlab
RECORDS_COLUMN = 'records'
def initialize(finder_query, model, order_by_columns)
verify_order_by_attributes_on_model!(model, order_by_columns)
@finder_query = finder_query
@order_by_columns = order_by_columns
@table_name = model.table_name
......@@ -34,6 +36,20 @@ module Gitlab
private
attr_reader :finder_query, :order_by_columns, :table_name
def verify_order_by_attributes_on_model!(model, order_by_columns)
order_by_columns.map(&:column).each do |column|
unless model.columns_hash[column.attribute_name.to_s]
text = <<~TEXT
The "RecordLoaderStrategy" does not support the following ORDER BY column because
it's not available on the \"#{model.table_name}\" table: #{column.attribute_name}
Omit the "finder_query" parameter to use the "OrderValuesLoaderStrategy".
TEXT
raise text
end
end
end
end
end
end
......
# frozen_string_literal: true
module Gitlab
module Pagination
module Keyset
class SqlTypeMissingError < StandardError
def self.for_column(column)
message = <<~TEXT
The "sql_type" attribute is not set for the following column definition:
#{column.attribute_name}
See the ColumnOrderDefinition class for more context.
TEXT
new(message)
end
end
end
end
end
......@@ -100,7 +100,7 @@ module Sidebars
::Sidebars::MenuItem.new(
title: _('Google Cloud'),
link: project_google_cloud_index_path(context.project),
active_routes: { controller: [:google_cloud, :service_accounts] },
active_routes: { controller: [:google_cloud, :service_accounts, :deployments] },
item_id: :google_cloud
)
end
......
......@@ -6906,6 +6906,9 @@ msgstr ""
msgid "Checkout|(x%{quantity})"
msgstr ""
msgid "Checkout|An unknown error has occurred. Please try again by refreshing this page."
msgstr ""
msgid "Checkout|Billing address"
msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Ci::Status::Build::WaitingForApproval do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
subject { described_class.new(Gitlab::Ci::Status::Core.new(build, user)) }
describe '#illustration' do
let(:build) { create(:ci_build, :manual, environment: 'production', project: project) }
before do
environment = create(:environment, name: 'production', project: project)
create(:deployment, :blocked, project: project, environment: environment, deployable: build)
end
it { expect(subject.illustration).to include(:image, :size) }
it { expect(subject.illustration[:title]).to eq('Waiting for approval') }
it { expect(subject.illustration[:content]).to include('This job deploys to the protected environment "production"') }
end
describe '.matches?' do
subject { described_class.matches?(build, user) }
let(:build) { create(:ci_build, :manual, environment: 'production', project: project) }
before do
create(:deployment, deployment_status, deployable: build, project: project)
end
context 'when build is waiting for approval' do
let(:deployment_status) { :blocked }
it 'is a correct match' do
expect(subject).to be_truthy
end
end
context 'when build is not waiting for approval' do
let(:deployment_status) { :created }
it 'does not match' do
expect(subject).to be_falsey
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::OrderByColumnData do
let(:arel_table) { Issue.arel_table }
let(:column) do
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: :id,
column_expression: arel_table[:id],
order_expression: arel_table[:id].desc
)
end
subject(:column_data) { described_class.new(column, 'column_alias', arel_table) }
describe '#arel_column' do
it 'delegates to column_expression' do
expect(column_data.arel_column).to eq(column.column_expression)
end
end
describe '#column_for_projection' do
it 'returns the expression with AS using the original column name' do
expect(column_data.column_for_projection.to_sql).to eq('"issues"."id" AS id')
end
end
describe '#projection' do
it 'returns the expression with AS using the specified column lias' do
expect(column_data.projection.to_sql).to eq('"issues"."id" AS column_alias')
end
end
end
......@@ -33,14 +33,14 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder
]
end
shared_examples 'correct ordering examples' do
let(:iterator) do
Gitlab::Pagination::Keyset::Iterator.new(
scope: scope.limit(batch_size),
in_operator_optimization_options: in_operator_optimization_options
)
end
let(:iterator) do
Gitlab::Pagination::Keyset::Iterator.new(
scope: scope.limit(batch_size),
in_operator_optimization_options: in_operator_optimization_options
)
end
shared_examples 'correct ordering examples' do |opts = {}|
let(:all_records) do
all_records = []
iterator.each_batch(of: batch_size) do |records|
......@@ -49,8 +49,10 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder
all_records
end
it 'returns records in correct order' do
expect(all_records).to eq(expected_order)
unless opts[:skip_finder_query_test]
it 'returns records in correct order' do
expect(all_records).to eq(expected_order)
end
end
context 'when not passing the finder query' do
......@@ -248,4 +250,57 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder
expect { described_class.new(**options).execute }.to raise_error(/The order on the scope does not support keyset pagination/)
end
context 'when ordering by SQL expression' do
let(:order) do
# ORDER BY (id * 10), id
Gitlab::Pagination::Keyset::Order.build([
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'id_multiplied_by_ten',
order_expression: Arel.sql('(id * 10)').asc,
sql_type: 'integer'
),
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: :id,
order_expression: Issue.arel_table[:id].asc
)
])
end
let(:scope) { Issue.reorder(order) }
let(:expected_order) { issues.sort_by(&:id) }
let(:in_operator_optimization_options) do
{
array_scope: Project.where(namespace_id: top_level_group.self_and_descendants.select(:id)).select(:id),
array_mapping_scope: -> (id_expression) { Issue.where(Issue.arel_table[:project_id].eq(id_expression)) }
}
end
context 'when iterating records one by one' do
let(:batch_size) { 1 }
it_behaves_like 'correct ordering examples', skip_finder_query_test: true
end
context 'when iterating records with LIMIT 3' do
let(:batch_size) { 3 }
it_behaves_like 'correct ordering examples', skip_finder_query_test: true
end
context 'when passing finder query' do
let(:batch_size) { 3 }
it 'raises error, loading complete rows are not supported with SQL expressions' do
in_operator_optimization_options[:finder_query] = -> (_, _) { Issue.select(:id, '(id * 10)').where(id: -1) }
expect(in_operator_optimization_options[:finder_query]).not_to receive(:call)
expect do
iterator.each_batch(of: batch_size) { |records| records.to_a }
end.to raise_error /The "RecordLoaderStrategy" does not support/
end
end
end
end
......@@ -31,4 +31,41 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::Strategies::O
])
end
end
context 'when an SQL expression is given' do
context 'when the sql_type attribute is missing' do
let(:order) do
Gitlab::Pagination::Keyset::Order.build([
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'id_times_ten',
order_expression: Arel.sql('id * 10').asc
)
])
end
let(:keyset_scope) { Project.order(order) }
it 'raises error' do
expect { strategy.initializer_columns }.to raise_error(Gitlab::Pagination::Keyset::SqlTypeMissingError)
end
end
context 'when the sql_type_attribute is present' do
let(:order) do
Gitlab::Pagination::Keyset::Order.build([
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'id_times_ten',
order_expression: Arel.sql('id * 10').asc,
sql_type: 'integer'
)
])
end
let(:keyset_scope) { Project.order(order) }
it 'returns the initializer columns' do
expect(strategy.initializer_columns).to eq(['NULL::integer AS id_times_ten'])
end
end
end
end
......@@ -11,8 +11,6 @@ RSpec.describe GroupPolicy do
it do
expect_allowed(:read_group)
expect_allowed(:read_crm_organization)
expect_allowed(:read_crm_contact)
expect_allowed(:read_counts)
expect_allowed(*read_group_permissions)
expect_disallowed(:upload_file)
......@@ -21,11 +19,13 @@ RSpec.describe GroupPolicy do
expect_disallowed(*maintainer_permissions)
expect_disallowed(*owner_permissions)
expect_disallowed(:read_namespace)
expect_disallowed(:read_crm_organization)
expect_disallowed(:read_crm_contact)
end
end
context 'with no user and public project' do
let(:project) { create(:project, :public) }
let(:project) { create(:project, :public, group: create(:group, :crm_enabled)) }
let(:current_user) { nil }
before do
......@@ -41,7 +41,7 @@ RSpec.describe GroupPolicy do
end
context 'with foreign user and public project' do
let(:project) { create(:project, :public) }
let(:project) { create(:project, :public, group: create(:group, :crm_enabled)) }
let(:current_user) { create(:user) }
before do
......@@ -67,7 +67,7 @@ RSpec.describe GroupPolicy do
it { expect_allowed(*read_group_permissions) }
context 'in subgroups' do
let(:subgroup) { create(:group, :private, parent: group) }
let(:subgroup) { create(:group, :private, :crm_enabled, parent: group) }
let(:project) { create(:project, namespace: subgroup) }
it { expect_allowed(*read_group_permissions) }
......@@ -235,7 +235,7 @@ RSpec.describe GroupPolicy do
describe 'private nested group use the highest access level from the group and inherited permissions' do
let_it_be(:nested_group) do
create(:group, :private, :owner_subgroup_creation_only, parent: group)
create(:group, :private, :owner_subgroup_creation_only, :crm_enabled, parent: group)
end
before_all do
......@@ -342,7 +342,7 @@ RSpec.describe GroupPolicy do
let(:current_user) { owner }
context 'when the group share_with_group_lock is enabled' do
let(:group) { create(:group, share_with_group_lock: true, parent: parent) }
let(:group) { create(:group, :crm_enabled, share_with_group_lock: true, parent: parent) }
before do
group.add_owner(owner)
......@@ -350,10 +350,10 @@ RSpec.describe GroupPolicy do
context 'when the parent group share_with_group_lock is enabled' do
context 'when the group has a grandparent' do
let(:parent) { create(:group, share_with_group_lock: true, parent: grandparent) }
let(:parent) { create(:group, :crm_enabled, share_with_group_lock: true, parent: grandparent) }
context 'when the grandparent share_with_group_lock is enabled' do
let(:grandparent) { create(:group, share_with_group_lock: true) }
let(:grandparent) { create(:group, :crm_enabled, share_with_group_lock: true) }
context 'when the current_user owns the parent' do
before do
......@@ -379,7 +379,7 @@ RSpec.describe GroupPolicy do
end
context 'when the grandparent share_with_group_lock is disabled' do
let(:grandparent) { create(:group) }
let(:grandparent) { create(:group, :crm_enabled) }
context 'when the current_user owns the parent' do
before do
......@@ -396,7 +396,7 @@ RSpec.describe GroupPolicy do
end
context 'when the group does not have a grandparent' do
let(:parent) { create(:group, share_with_group_lock: true) }
let(:parent) { create(:group, :crm_enabled, share_with_group_lock: true) }
context 'when the current_user owns the parent' do
before do
......@@ -413,7 +413,7 @@ RSpec.describe GroupPolicy do
end
context 'when the parent group share_with_group_lock is disabled' do
let(:parent) { create(:group) }
let(:parent) { create(:group, :crm_enabled) }
it { expect_allowed(:change_share_with_group_lock) }
end
......@@ -698,7 +698,7 @@ RSpec.describe GroupPolicy do
end
it_behaves_like 'clusterable policies' do
let(:clusterable) { create(:group) }
let(:clusterable) { create(:group, :crm_enabled) }
let(:cluster) do
create(:cluster,
:provided_by_gcp,
......@@ -708,7 +708,7 @@ RSpec.describe GroupPolicy do
end
describe 'update_max_artifacts_size' do
let(:group) { create(:group, :public) }
let(:group) { create(:group, :public, :crm_enabled) }
context 'when no user' do
let(:current_user) { nil }
......@@ -738,7 +738,7 @@ RSpec.describe GroupPolicy do
end
describe 'design activity' do
let_it_be(:group) { create(:group, :public) }
let_it_be(:group) { create(:group, :public, :crm_enabled) }
let(:current_user) { nil }
......@@ -935,8 +935,6 @@ RSpec.describe GroupPolicy do
it { is_expected.to be_allowed(:read_package) }
it { is_expected.to be_allowed(:read_group) }
it { is_expected.to be_allowed(:read_crm_organization) }
it { is_expected.to be_allowed(:read_crm_contact) }
it { is_expected.to be_disallowed(:create_package) }
end
......@@ -946,8 +944,6 @@ RSpec.describe GroupPolicy do
it { is_expected.to be_allowed(:create_package) }
it { is_expected.to be_allowed(:read_package) }
it { is_expected.to be_allowed(:read_group) }
it { is_expected.to be_allowed(:read_crm_organization) }
it { is_expected.to be_allowed(:read_crm_contact) }
it { is_expected.to be_disallowed(:destroy_package) }
end
......@@ -967,7 +963,7 @@ RSpec.describe GroupPolicy do
it_behaves_like 'Self-managed Core resource access tokens'
context 'support bot' do
let_it_be(:group) { create(:group, :private) }
let_it_be(:group) { create(:group, :private, :crm_enabled) }
let_it_be(:current_user) { User.support_bot }
before do
......@@ -977,7 +973,7 @@ RSpec.describe GroupPolicy do
it { expect_disallowed(:read_label) }
context 'when group hierarchy has a project with service desk enabled' do
let_it_be(:subgroup) { create(:group, :private, parent: group) }
let_it_be(:subgroup) { create(:group, :private, :crm_enabled, parent: group) }
let_it_be(:project) { create(:project, group: subgroup, service_desk_enabled: true) }
it { expect_allowed(:read_label) }
......@@ -1170,7 +1166,7 @@ RSpec.describe GroupPolicy do
end
context 'when crm_enabled is false' do
let(:group) { create(:group) }
let(:group) { create(:group, :crm_enabled) }
let(:current_user) { owner }
it { is_expected.to be_disallowed(:read_crm_contact) }
......
......@@ -108,6 +108,7 @@ RSpec.describe ::Packages::Npm::PackagePresenter do
context 'with packages_installable_package_files disabled' do
before do
stub_feature_flags(packages_installable_package_files: false)
package2.package_files.id_not_in(package_file_pending_destruction.id).delete_all
end
it 'returns them' do
......
......@@ -73,7 +73,7 @@ RSpec.describe Groups::Crm::ContactsController do
let(:group) { create(:group, :public, :crm_enabled) }
context 'with anonymous user' do
it_behaves_like 'ok response with index template'
it_behaves_like 'response with 404 status'
end
end
end
......
......@@ -73,7 +73,7 @@ RSpec.describe Groups::Crm::OrganizationsController do
let(:group) { create(:group, :public, :crm_enabled) }
context 'with anonymous user' do
it_behaves_like 'ok response with index template'
it_behaves_like 'response with 404 status'
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::GoogleCloud::DeploymentsController do
let_it_be(:project) { create(:project, :public) }
let_it_be(:user_guest) { create(:user) }
let_it_be(:user_developer) { create(:user) }
let_it_be(:user_maintainer) { create(:user) }
let_it_be(:user_creator) { project.creator }
let_it_be(:unauthorized_members) { [user_guest, user_developer] }
let_it_be(:authorized_members) { [user_maintainer, user_creator] }
let_it_be(:urls_list) { %W[#{project_google_cloud_deployments_cloud_run_path(project)} #{project_google_cloud_deployments_cloud_storage_path(project)}] }
before do
project.add_guest(user_guest)
project.add_developer(user_developer)
project.add_maintainer(user_maintainer)
end
describe "Routes must be restricted behind Google OAuth2" do
context 'when a public request is made' do
it 'returns not found on GET request' do
urls_list.each do |url|
get url
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
context 'when unauthorized members make requests' do
it 'returns not found on GET request' do
urls_list.each do |url|
unauthorized_members.each do |unauthorized_member|
sign_in(unauthorized_member)
get url
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
context 'when authorized members make requests' do
it 'redirects on GET request' do
urls_list.each do |url|
authorized_members.each do |authorized_member|
sign_in(authorized_member)
get url
expect(response).to redirect_to(assigns(:authorize_url))
end
end
end
end
end
describe 'Authorized GET project/-/google_cloud/deployments/cloud_run' do
let_it_be(:url) { "#{project_google_cloud_deployments_cloud_run_path(project)}" }
before do
allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |client|
allow(client).to receive(:validate_token).and_return(true)
end
end
it 'renders placeholder' do
authorized_members.each do |authorized_member|
sign_in(authorized_member)
get url
expect(response).to have_gitlab_http_status(:ok)
end
end
end
describe 'Authorized GET project/-/google_cloud/deployments/cloud_storage' do
let_it_be(:url) { "#{project_google_cloud_deployments_cloud_storage_path(project)}" }
before do
allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |client|
allow(client).to receive(:validate_token).and_return(true)
end
end
it 'renders placeholder' do
authorized_members.each do |authorized_member|
sign_in(authorized_member)
get url
expect(response).to have_gitlab_http_status(:ok)
end
end
end
end
......@@ -71,6 +71,24 @@ RSpec.describe Ci::ProcessSyncEventsService do
expect { execute }.not_to change(Projects::SyncEvent, :count)
end
end
it 'does not delete non-executed events' do
new_project = create(:project)
sync_event_class.delete_all
project1.update!(group: parent_group_2)
new_project.update!(group: parent_group_1)
project2.update!(group: parent_group_1)
new_project_sync_event = new_project.sync_events.last
allow(sync_event_class).to receive(:preload_synced_relation).and_return(
sync_event_class.where.not(id: new_project_sync_event)
)
expect { execute }.to change(Projects::SyncEvent, :count).from(3).to(1)
expect(new_project_sync_event.reload).to be_persisted
end
end
context 'for Namespaces::SyncEvent' do
......
......@@ -22,10 +22,10 @@ RSpec.describe Issues::UpdateService, :mailer do
end
before_all do
project.add_maintainer(user)
project.add_developer(user2)
project.add_developer(user3)
project.add_guest(guest)
group.add_maintainer(user)
group.add_developer(user2)
group.add_developer(user3)
group.add_guest(guest)
end
describe 'execute' do
......
......@@ -28,6 +28,8 @@ RSpec.shared_context 'GroupPolicy context' do
read_metrics_dashboard_annotation
read_prometheus
read_package_settings
read_crm_contact
read_crm_organization
]
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