209 行
7.9 KiB
Ruby
209 行
7.9 KiB
Ruby
# Population first, then filters. Previous versions are joined before period filtering.
|
|
class CountsPopulation
|
|
attr_reader :definition, :params, :at, :history, :scope, :timezone
|
|
|
|
def initialize(params)
|
|
@params = params
|
|
@definition = CountsResourceDefinition.new(params.fetch('resource', 'posts'))
|
|
@history = params.fetch('definition', 'latest') == 'history'
|
|
raise ArgumentError unless ['latest', 'history'].include?(params.fetch('definition', 'latest'))
|
|
|
|
@timezone = TZInfo::Timezone.get(params.fetch('timezone', 'UTC'))
|
|
@at = timestamp(params['execution_at']) || Time.current.utc
|
|
raise ArgumentError if at > Time.current + 1.second
|
|
|
|
@scope = definition.model.all
|
|
make_population
|
|
apply_filters
|
|
rescue TZInfo::InvalidTimezoneIdentifier
|
|
raise ArgumentError
|
|
end
|
|
|
|
def timestamp(value, end_date: false)
|
|
return nil if value.nil? || value == ''
|
|
raise ArgumentError unless value.is_a?(String)
|
|
|
|
if value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
date = Date.iso8601(value)
|
|
date += 1 if end_date
|
|
timezone.local_to_utc(Time.utc(date.year, date.month, date.day))
|
|
else
|
|
raise ArgumentError unless value.match?(/(?:Z|[+-]\d{2}:\d{2})\z/)
|
|
|
|
time = Time.iso8601(value).utc
|
|
raise ArgumentError unless time.year.between?(1900, 2100)
|
|
|
|
time
|
|
end
|
|
rescue TZInfo::AmbiguousTime, TZInfo::PeriodNotFound, Date::Error
|
|
raise ArgumentError
|
|
end
|
|
|
|
def latest_sql(row = 'v', cutoff = nil)
|
|
cutoff ||= history ? at : timestamp(params['at']) || at
|
|
keys = definition.keys.map { "newer.#{ _1 } = #{ row }.#{ _1 }" }.join(' AND ')
|
|
bound = definition.model.connection.quote(cutoff)
|
|
<<~SQL.squish
|
|
NOT EXISTS (SELECT 1 FROM #{ definition.table } newer
|
|
WHERE #{ keys } AND newer.version_no > #{ row }.version_no
|
|
AND newer.#{ definition.time_column } <= #{ bound })
|
|
SQL
|
|
end
|
|
|
|
def changed_sql(key)
|
|
column = definition.column(key)
|
|
"(previous.id IS NOT NULL AND NOT (v.#{ column } <=> previous.#{ column }))"
|
|
end
|
|
|
|
private
|
|
|
|
def make_population
|
|
table = definition.table
|
|
@scope = @scope.from("#{ table } v").select('v.*')
|
|
keys = definition.keys.map { "previous.#{ _1 } = v.#{ _1 }" }.join(' AND ')
|
|
@scope = @scope.joins("LEFT JOIN #{ table } previous ON #{ keys } " +
|
|
'AND previous.version_no = v.version_no - 1')
|
|
@scope = @scope.where("v.#{ definition.time_column } <= ?", at)
|
|
unless history
|
|
point = timestamp(params['at']) || at
|
|
raise ArgumentError if point > at
|
|
|
|
@scope = @scope.where("v.#{ definition.time_column } <= ?", point)
|
|
.where(Arel.sql(latest_sql('v', point))).where.not('v.event_type' => 'discard')
|
|
return
|
|
end
|
|
first = timestamp(params['from'])
|
|
last = timestamp(params['through'], end_date: true)
|
|
raise ArgumentError if first && last && first > last
|
|
|
|
@scope = @scope.where('v.created_at >= ?', first) if first
|
|
if last
|
|
operator = params['through'].length == 10 ? '<' : '<='
|
|
@scope = @scope.where("v.created_at #{ operator } ?", last)
|
|
end
|
|
end
|
|
|
|
def apply_filters
|
|
['resource_id', 'user'].each do |key|
|
|
value = params[key == 'user' ? 'user_id' : key]
|
|
next if value.nil? || value == ''
|
|
raise ArgumentError unless value.match?(/\A[0-9]+\z/)
|
|
|
|
@scope = @scope.where("#{ definition.expression(key) } = ?", value.to_i)
|
|
end
|
|
apply_tags if params['tags'].present? || params['tag'].present?
|
|
conditions = JSON.parse(params.fetch('conditions', '[]'))
|
|
raise ArgumentError unless conditions.is_a?(Array) && conditions.length <= 100
|
|
|
|
conditions.each { apply_condition(_1) }
|
|
drills = JSON.parse(params.fetch('drill', '[]'))
|
|
raise ArgumentError unless drills.is_a?(Array) && drills.length <= 100
|
|
|
|
drills.each do |entry|
|
|
key = entry.fetch('field')
|
|
if key == 'tags'
|
|
@scope = @scope.where(snapshot_membership(entry.fetch('value')))
|
|
elsif key == 'changed_field'
|
|
raise ArgumentError unless history && definition.changes.include?(entry['value'])
|
|
|
|
@scope = @scope.where(Arel.sql(changed_sql(entry['value'])))
|
|
elsif definition.fields[key] == :datetime && entry['grain']
|
|
@scope = @scope.where(definition.expression(key) => nil) if entry['value'].nil?
|
|
if entry['value']
|
|
expression = CountsAxes.time_expression(definition.expression(key),
|
|
entry['grain'], timezone)
|
|
@scope = @scope.where("#{ expression } = ?", entry['value'])
|
|
end
|
|
else
|
|
raise ArgumentError unless definition.axes(history).include?(key)
|
|
|
|
@scope = @scope.where(definition.expression(key) => entry['value'])
|
|
end
|
|
end
|
|
end
|
|
|
|
def apply_condition(entry)
|
|
raise ArgumentError unless entry.is_a?(Hash)
|
|
raise ArgumentError if (entry.keys - ['field', 'operator', 'value', 'end', 'blank']).any?
|
|
|
|
key = entry.fetch('field')
|
|
type = definition.fields.fetch(key) { raise ArgumentError }
|
|
operator = entry.fetch('operator')
|
|
raise ArgumentError unless CountsResourceDefinition::OPERATORS.fetch(type, []).include?(operator)
|
|
raise ArgumentError unless [true, false, nil].include?(entry['blank'])
|
|
|
|
expression = definition.expression(key)
|
|
blank = type == :string || type == :enum ?
|
|
"(#{ expression } IS NULL OR #{ expression } = '')" : "#{ expression } IS NULL"
|
|
if entry['blank']
|
|
@scope = @scope.where(Arel.sql(blank))
|
|
return
|
|
end
|
|
values = [entry['value'], entry['end']]
|
|
raise ArgumentError unless values.all? { _1.nil? || _1.is_a?(String) }
|
|
return if values.all? { _1.nil? || _1 == '' }
|
|
|
|
parsed = values.map do |value|
|
|
next nil if value.nil? || value == ''
|
|
|
|
case type
|
|
when :integer
|
|
raise ArgumentError unless value.match?(/\A-?\d+\z/)
|
|
|
|
value.to_i
|
|
when :datetime then timestamp(value)
|
|
else value
|
|
end
|
|
end
|
|
if operator == 'range'
|
|
@scope = @scope.where("#{ expression } >= ?", parsed[0]) if parsed[0]
|
|
@scope = @scope.where("#{ expression } <= ?", parsed[1]) if parsed[1]
|
|
return
|
|
end
|
|
return unless parsed[0]
|
|
|
|
sql_operator = { 'eq' => '=', 'ne' => '<>', 'gt' => '>', 'lt' => '<',
|
|
'ge' => '>=', 'le' => '<=', 'like' => 'LIKE' }.fetch(operator)
|
|
sql = "#{ expression } #{ sql_operator } ?"
|
|
sql = "(#{ sql } OR #{ blank })" if operator == 'ne'
|
|
@scope = @scope.where(sql, parsed[0])
|
|
end
|
|
|
|
def snapshot_membership(value)
|
|
kind, identifier = value.to_s.split(':', 2)
|
|
raise ArgumentError unless ['internal', 'external'].include?(kind)
|
|
raise ArgumentError unless identifier&.match?(/\A[1-9]\d*\z/)
|
|
|
|
key = kind == 'internal' ? 'tag_id' : 'external_tag_id'
|
|
definition.model.sanitize_sql_array([
|
|
"JSON_CONTAINS(v.tags_json, JSON_OBJECT('#{ key }', CAST(? AS UNSIGNED)))",
|
|
identifier.to_i])
|
|
end
|
|
|
|
def apply_tags
|
|
raise ArgumentError unless definition.resource == 'posts'
|
|
|
|
if params['tag'].present?
|
|
@scope = @scope.where(snapshot_membership(
|
|
"#{ params.fetch('kind', 'internal') }:#{ params['tag'] }"))
|
|
end
|
|
return unless params['tags'].present?
|
|
|
|
locale = Locale.find_by!(code: params.fetch('locale', 'ja'))
|
|
refs = PostTagSearch.parse_references(params['tag_refs'], params['tags'].split)
|
|
raw = params['tags']
|
|
literals = PostTagSearch.literals(locale, raw, references: refs)
|
|
predicates = literals.map do |literal|
|
|
internal, external = PostTagSearch.references(locale, literal)
|
|
parts = Array(internal).map { snapshot_membership("internal:#{ _1 }") } +
|
|
Array(external).map { snapshot_membership("external:#{ _1 }") }
|
|
expression = parts.empty? ? 'FALSE' : "(#{ parts.join(' OR ') })"
|
|
literal[:negative] ? "NOT #{ expression }" : expression
|
|
end
|
|
return if predicates.empty?
|
|
|
|
any = params['match'] == 'any' || raw.split.any? { _1.downcase == 'type:or' }
|
|
@scope = @scope.where(Arel.sql("(#{ predicates.join(any ? ' OR ' : ' AND ') })"))
|
|
end
|
|
end
|