Commit e88fd1a4 authored by Alex Kalderimis's avatar Alex Kalderimis

Create DesignAtVersion model

This adds a new model entity `DesignManagement::DesignAtVersion`, which
wraps a design and an associated version. Mechanisms are provided for
lazy-finding and validation.

These do not have mutable properties, and validate the important
constraint that they share an issue

Finding DesignManagement::DesignAtVersion objects requires querying both
the designs and the versions tables, and is at present more expensive
than a single fetch. It also returns an Array instead of a relation,
since this is not a ActiveModel object. We validate invariants in find,
this ensures we never fetch invalid objects.

* Validations:
  - checking that there is both a design and a version
  - checking that the design and the version have the same issue
  - checking that both the design and the version have an issue

  These validations are checked during `find` and when calling
  `instantiate`

* ID

  It is important for GraphQL and the front-end that the model has an ID
  that can be used to lookup each combination, and be used to store this
  identity on the client.

* deleted? and status methods

  These supplement their analogues in DesignManagement::Design with an
  awareness of the current version. Thus a
  DesignManagement::DesignAtVersion as of a version in which is was
  deleted will respond to `deleted?` appropriately.

  Callers should be be careful to distinguish between `dav.state ==
  :current` and `!dav.deleted?` - as of a version before a design has
  been created it will respond with:
   * `#deleted? == false`
   * `#status == :not_created_yet`
  Reflecting the fact that this is not deleted because it has not been
  created yet.

This requires changes to the design and version factories to be able to
create valid objects with nil issues, a condition that is found when
these objects are being imported.
parent 36408a64
# frozen_string_literal: true
# Tuple of design and version
# * has a composite ID, with lazy_find
module DesignManagement
class DesignAtVersion
include ActiveModel::Validations
include GlobalID::Identification
include Gitlab::Utils::StrongMemoize
attr_reader :version
attr_reader :design
validates_presence_of :version
validates_presence_of :design
validate :design_and_version_belong_to_the_same_issue
validate :design_and_version_have_issue_id
def initialize(design: nil, version: nil)
@design, @version = design, version
end
def self.instantiate(attrs)
new(attrs).tap { |obj| obj.validate! }
end
# The ID, needed by GraphQL types and as part of the Lazy-fetch
# protocol, includes information about both the design and the version.
#
# The particular format is not interesting, and should be treated as opaque
# by all callers.
def id
"#{design.id}.#{version.id}"
end
def self.lazy_find(id)
BatchLoader.for(id).batch do |ids, callback|
find(ids).each do |record|
callback.call(record.id, record)
end
end
end
def self.find(ids)
pairs = ids.map { |id| id.split('.').map(&:to_i) }
design_ids = pairs.map(&:first).uniq
version_ids = pairs.map(&:second).uniq
designs = ::DesignManagement::Design
.where(id: design_ids)
.index_by(&:id)
versions = ::DesignManagement::Version
.where(id: version_ids)
.index_by(&:id)
pairs.map do |(design_id, version_id)|
design = designs[design_id]
version = versions[version_id]
obj = new(design: design, version: version)
obj if obj.valid?
end.compact
end
def status
if not_created_yet?
:not_created_yet
elsif deleted?
:deleted
else
:current
end
end
def deleted?
action&.deletion?
end
def not_created_yet?
action.nil?
end
private
def action
strong_memoize(:most_recent_action) do
::DesignManagement::Action
.most_recent.up_to_version(version)
.where(design: design)
.first
end
end
def design_and_version_belong_to_the_same_issue
id_a, id_b = [design, version].map { |obj| obj&.issue_id }
return if id_a == id_b
errors.add(:issue, "must be the same on design and version")
end
def design_and_version_have_issue_id
return if [design, version].all? { |obj| obj.try(:issue_id).present? }
errors.add(:issue, "must be present on both design and version")
end
end
end
# frozen_string_literal: true
module DesignManagement
class DesignAtVersionPolicy < ::BasePolicy
delegate { @subject.version }
delegate { @subject.design }
end
end
# frozen_string_literal: true
FactoryBot.define do
factory :design_at_version, class: DesignManagement::DesignAtVersion do
skip_create # This is not an Active::Record model.
design { nil }
version { nil }
transient do
issue { design&.issue || version&.issue || create(:issue) }
end
initialize_with do
attrs = attributes.dup
attrs[:design] ||= create(:design, issue: issue)
attrs[:version] ||= create(:design_version, issue: issue)
new(attrs)
end
end
end
......@@ -3,13 +3,25 @@
FactoryBot.define do
factory :design, class: DesignManagement::Design do
issue { create(:issue) }
project { issue.project }
project { issue&.project || create(:project) }
sequence(:filename) { |n| "homescreen-#{n}.jpg" }
transient do
author { issue.author }
end
trait :importing do
issue { nil }
importing { true }
imported { false }
end
trait :imported do
importing { false }
imported { true }
end
create_versions = ->(design, evaluator, commit_version) do
unless evaluator.versions_count.zero?
project = design.project
......
......@@ -4,7 +4,7 @@ FactoryBot.define do
factory :design_version, class: DesignManagement::Version do
sequence(:sha) { |n| Digest::SHA1.hexdigest("commit-like-#{n}") }
issue { designs.first&.issue || create(:issue) }
author { issue.author || create(:user) }
author { issue&.author || create(:user) }
transient do
designs_count { 1 }
......@@ -18,6 +18,19 @@ FactoryBot.define do
designs_count { 0 }
end
trait :importing do
issue { nil }
designs_count { 0 }
importing { true }
imported { false }
end
trait :imported do
importing { false }
imported { true }
end
after(:build) do |version, evaluator|
# By default all designs are created_designs, so just add them.
specific_designs = [].concat(
......
This diff is collapsed.
......@@ -2,6 +2,8 @@
require 'spec_helper'
describe DesignManagement::Version do
let_it_be(:issue) { create(:issue) }
describe 'relations' do
it { is_expected.to have_many(:actions) }
it { is_expected.to have_many(:designs).through(:actions) }
......@@ -82,7 +84,6 @@ describe DesignManagement::Version do
end
let_it_be(:author) { create(:user) }
let_it_be(:issue) { create(:issue) }
let_it_be(:design_a) { create(:design, issue: issue) }
let_it_be(:design_b) { create(:design, issue: issue) }
let_it_be(:designs) { [design_a, design_b] }
......@@ -291,4 +292,51 @@ describe DesignManagement::Version do
expect(version.author).to eq(commit_user)
end
end
describe '#diff_refs' do
let(:project) { issue.project }
before do
expect(project.design_repository).to receive(:commit)
.once
.with(sha)
.and_return(commit)
end
subject { create(:design_version, issue: issue, sha: sha) }
context 'there is a commit in the repo by the SHA' do
let(:commit) { build(:commit) }
let(:sha) { commit.id }
it { is_expected.to have_attributes(diff_refs: commit.diff_refs) }
it 'memoizes calls to #diff_refs' do
expect(subject.diff_refs).to eq(subject.diff_refs)
end
end
context 'there is no commit in the repo by the SHA' do
let(:commit) { nil }
let(:sha) { Digest::SHA1.hexdigest("points to nothing") }
it { is_expected.to have_attributes(diff_refs: be_nil) }
end
end
describe '#reset' do
subject { create(:design_version, issue: issue) }
it 'removes memoized values' do
expect(subject).to receive(:commit).twice.and_return(nil)
subject.diff_refs
subject.diff_refs
subject.reset
subject.diff_refs
subject.diff_refs
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