このコミットが含まれているのは:
@@ -82,6 +82,16 @@ class CountsCondition
|
|||||||
|
|
||||||
def apply(scope, entry)
|
def apply(scope, entry)
|
||||||
raise ArgumentError unless entry.is_a?(Hash)
|
raise ArgumentError unless entry.is_a?(Hash)
|
||||||
|
if entry['operator'] == 'axis_eq'
|
||||||
|
raise ArgumentError unless (entry.keys - ['field', 'operator', 'grain', 'value']).empty?
|
||||||
|
value = entry['value']
|
||||||
|
unless value.nil? || value.is_a?(String) || value.is_a?(Numeric)
|
||||||
|
raise ArgumentError
|
||||||
|
end
|
||||||
|
raise ArgumentError if value.is_a?(String) && value.bytesize > 8192
|
||||||
|
|
||||||
|
return @population.axis_condition(scope, entry)
|
||||||
|
end
|
||||||
allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values']
|
allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values']
|
||||||
raise ArgumentError if (entry.keys - allowed).any?
|
raise ArgumentError if (entry.keys - allowed).any?
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,29 @@ class CountsPopulation
|
|||||||
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
|
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def axis_condition(scope, 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
|
||||||
|
scope
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def make_population
|
def make_population
|
||||||
@@ -69,7 +92,8 @@ class CountsPopulation
|
|||||||
@scope = @scope.from("#{ table } v").select('v.*')
|
@scope = @scope.from("#{ table } v").select('v.*')
|
||||||
previous = ['rows', 'cols'].any? do |key|
|
previous = ['rows', 'cols'].any? do |key|
|
||||||
QueryState.axes(params.fetch(key, '')).any? { _1['field'] == 'changed_field' }
|
QueryState.axes(params.fetch(key, '')).any? { _1['field'] == 'changed_field' }
|
||||||
end || QueryState.records(params, 'drill').any? { _1['field'] == 'changed_field' }
|
end || (QueryState.records(params, 'drill') + QueryState.records(params, 'conditions'))
|
||||||
|
.any? { _1['field'] == 'changed_field' }
|
||||||
if previous
|
if previous
|
||||||
keys = definition.keys.map { "previous.#{ _1 } = v.#{ _1 }" }.join(' AND ')
|
keys = definition.keys.map { "previous.#{ _1 } = v.#{ _1 }" }.join(' AND ')
|
||||||
@scope = @scope.joins("LEFT JOIN #{ table } previous ON #{ keys } " +
|
@scope = @scope.joins("LEFT JOIN #{ table } previous ON #{ keys } " +
|
||||||
@@ -127,25 +151,7 @@ class CountsPopulation
|
|||||||
raise ArgumentError unless drills.is_a?(Array) && drills.length <= 100
|
raise ArgumentError unless drills.is_a?(Array) && drills.length <= 100
|
||||||
|
|
||||||
drills.each do |entry|
|
drills.each do |entry|
|
||||||
key = entry.fetch('field')
|
@scope = axis_condition(@scope, entry)
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Public query grammar shared with frontend/src/lib/queryState.ts.
|
# Public query grammar shared with frontend/src/lib/queryState.ts.
|
||||||
class QueryState
|
class QueryState
|
||||||
SCHEMAS = {
|
SCHEMAS = {
|
||||||
'conditions' => ['field', 'operator', 'value', 'end', 'blank', 'input', 'values'],
|
'conditions' => ['field', 'operator', 'value', 'end', 'blank', 'input', 'values', 'grain', 'value_type'],
|
||||||
'condition_values' => ['condition', 'value', 'value_type'],
|
'condition_values' => ['condition', 'value', 'value_type'],
|
||||||
'drill' => ['field', 'grain', 'value', 'value_type'],
|
'drill' => ['field', 'grain', 'value', 'value_type'],
|
||||||
'tag_refs' => ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
|
'tag_refs' => ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
|
||||||
@@ -57,7 +57,8 @@ class QueryState
|
|||||||
|
|
||||||
records.sort.map do |_index, record|
|
records.sort.map do |_index, record|
|
||||||
type = record.delete('value_type')
|
type = record.delete('value_type')
|
||||||
if ['drill', 'measures', 'column_order', 'condition_values'].include?(key)
|
if ['drill', 'measures', 'column_order', 'condition_values'].include?(key) ||
|
||||||
|
(key == 'conditions' && record['operator'] == 'axis_eq')
|
||||||
raise ArgumentError unless ['null', 'string', 'number', 'boolean'].include?(type)
|
raise ArgumentError unless ['null', 'string', 'number', 'boolean'].include?(type)
|
||||||
|
|
||||||
raise ArgumentError unless type == 'null' || record.key?('value')
|
raise ArgumentError unless type == 'null' || record.key?('value')
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { countsConditionInputs, countsEventTypes, countsFieldLabel, countsFilter
|
|||||||
countsSetOperators, useCountsDefinition }
|
countsSetOperators, useCountsDefinition }
|
||||||
from '@/lib/countsDefinitions'
|
from '@/lib/countsDefinitions'
|
||||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||||
|
import { countsSummaryValue } from '@/lib/countsDisplay'
|
||||||
|
import { countsAxisLabel } from '@/lib/countsDefinitions'
|
||||||
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
@@ -97,6 +100,7 @@ const CountsAdditionalConditions = ({ params, update }: {
|
|||||||
const { label, message } = useUITranslation ('counts')
|
const { label, message } = useUITranslation ('counts')
|
||||||
const definition = useCountsDefinition ()
|
const definition = useCountsDefinition ()
|
||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
|
const locale = useLocale ()
|
||||||
const conditions = readCountState<CountsCondition[]> (params, 'conditions', [])
|
const conditions = readCountState<CountsCondition[]> (params, 'conditions', [])
|
||||||
const metadata = definition.filterFields ?? definition.fields
|
const metadata = definition.filterFields ?? definition.fields
|
||||||
const fields = metadata.filter (field => field.operators.length > 0 && !field.legacy)
|
const fields = metadata.filter (field => field.operators.length > 0 && !field.legacy)
|
||||||
@@ -153,7 +157,35 @@ const CountsAdditionalConditions = ({ params, update }: {
|
|||||||
<legend className="flex items-center gap-2">{label ('additionalConditions')}
|
<legend className="flex items-center gap-2">{label ('additionalConditions')}
|
||||||
<Button type="button" variant="outline" size="sm" disabled={fields.length === 0}
|
<Button type="button" variant="outline" size="sm" disabled={fields.length === 0}
|
||||||
onClick={() => edit ()}>{label ('add')}</Button></legend>
|
onClick={() => edit ()}>{label ('add')}</Button></legend>
|
||||||
{conditions.map ((condition, index) => (
|
{params.get ('tags') && params.get ('match') && <div
|
||||||
|
className="flex items-center gap-2">
|
||||||
|
<span>match = {params.get ('match')}</span>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
aria-label={message ('remove').text} onClick={() => update ('match', '')}>×</Button>
|
||||||
|
</div>}
|
||||||
|
{readCountState<Record<string, string | number | boolean>[]> (
|
||||||
|
params, 'tag_refs', []).map ((reference, index, references) => (
|
||||||
|
<div key={`reference-${index}`} className="flex flex-wrap items-center gap-2">
|
||||||
|
<span>{Object.entries (reference).map (([key, value]) =>
|
||||||
|
`${key}=${value}`).join (' ')}</span>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
aria-label={message ('remove').text}
|
||||||
|
onClick={() => update ('tag_refs', encodeCountState ('tag_refs',
|
||||||
|
references.filter ((_, position) => position !== index)))}>×</Button>
|
||||||
|
</div>))}
|
||||||
|
{conditions.map ((condition, index) => condition.operator === 'axis_eq' ? (
|
||||||
|
<div key={index} className="flex flex-wrap items-center gap-2 border-b py-2">
|
||||||
|
<span>{countsAxisLabel (condition, definition, locale).text}</span>
|
||||||
|
<span>{label ('operatorSymbols.eq')}</span>
|
||||||
|
<span>{condition.value == null ? 'NULL' : (condition.field === 'tags'
|
||||||
|
? condition.value : countsSummaryValue (
|
||||||
|
condition.value, condition, 0, { count: 0, page: 1, limit: 100 }, locale,
|
||||||
|
params.get ('timezone') ?? 'UTC'))}</span>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
aria-label={message ('remove').text}
|
||||||
|
onClick={() => write (conditions.filter ((_, position) => position !== index))}>
|
||||||
|
×</Button>
|
||||||
|
</div>) : (
|
||||||
<div key={index} className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-2
|
<div key={index} className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-2
|
||||||
border-b border-slate-200 py-2 dark:border-slate-700">
|
border-b border-slate-200 py-2 dark:border-slate-700">
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => edit (index)}
|
<Button type="button" variant="ghost" size="sm" onClick={() => edit (index)}
|
||||||
|
|||||||
@@ -86,10 +86,10 @@ const CountsSummaryTable = (
|
|||||||
: totals.get (JSON.stringify ([row.values, column.values])) ?? 0
|
: totals.get (JSON.stringify ([row.values, column.values])) ?? 0
|
||||||
const drill = (row: CountsPivotHeader, column: CountsPivotHeader) => {
|
const drill = (row: CountsPivotHeader, column: CountsPivotHeader) => {
|
||||||
const next = new URLSearchParams (params)
|
const next = new URLSearchParams (params)
|
||||||
const existing = readCountState<Record<string, string | number | null>[]> (params, 'drill', [])
|
const existing = readCountState<Record<string, string | number | null>[]> (params, 'conditions', [])
|
||||||
setCountState (next, 'drill', encodeCountState ('drill', [...existing,
|
setCountState (next, 'conditions', encodeCountState ('conditions', [...existing,
|
||||||
...row.values.map ((value, index) => ({ ...rows[index], value })),
|
...row.values.map ((value, index) => ({ ...rows[index], operator: 'axis_eq', value })),
|
||||||
...column.values.map ((value, index) => ({ ...cols[index], value }))]))
|
...column.values.map ((value, index) => ({ ...cols[index], operator: 'axis_eq', value }))]))
|
||||||
next.set ('mode', 'details')
|
next.set ('mode', 'details')
|
||||||
next.delete ('sort')
|
next.delete ('sort')
|
||||||
next.delete ('page')
|
next.delete ('page')
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { countsFieldLabel } from '@/lib/countsDefinitions'
|
|||||||
import { apiDownload, apiGet } from '@/lib/api'
|
import { apiDownload, apiGet } from '@/lib/api'
|
||||||
import { currentViewingLocale, localeLanguage } from '@/lib/locales'
|
import { currentViewingLocale, localeLanguage } from '@/lib/locales'
|
||||||
|
|
||||||
import { readCountState, encodeCountState } from '@/lib/queryState'
|
import { readCountState, encodeCountState, setCountState } from '@/lib/queryState'
|
||||||
|
|
||||||
import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
|
import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
|
||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
@@ -368,6 +368,22 @@ export const normaliseCountsParams = (
|
|||||||
definition?: CountsDefinition,
|
definition?: CountsDefinition,
|
||||||
): URLSearchParams => {
|
): URLSearchParams => {
|
||||||
const params = new URLSearchParams (source)
|
const params = new URLSearchParams (source)
|
||||||
|
const drills = readCountState<Record<string, unknown>[]> (params, 'drill', [])
|
||||||
|
if (params.get ('tag'))
|
||||||
|
{
|
||||||
|
drills.push ({ field: 'tags', value: `${params.get ('kind') ?? 'internal'}:${
|
||||||
|
params.get ('tag')}` })
|
||||||
|
params.delete ('tag')
|
||||||
|
params.delete ('kind')
|
||||||
|
}
|
||||||
|
if (drills.length > 0)
|
||||||
|
{
|
||||||
|
const conditions = readCountState<Record<string, unknown>[]> (params, 'conditions', [])
|
||||||
|
setCountState (params, 'conditions', encodeCountState ('conditions', [
|
||||||
|
...conditions, ...drills.map (entry => ({ ...entry, operator: 'axis_eq' }))]))
|
||||||
|
for (const key of [...params.keys ()])
|
||||||
|
if (key === 'drill' || key.startsWith ('drill.')) params.delete (key)
|
||||||
|
}
|
||||||
if (!params.has ('limit')) params.set ('limit', '100')
|
if (!params.has ('limit')) params.set ('limit', '100')
|
||||||
const nameScope = params.get ('name_scope')
|
const nameScope = params.get ('name_scope')
|
||||||
if (definition?.conditionAliases != null && (nameScope == null || nameScope === 'locale'))
|
if (definition?.conditionAliases != null && (nameScope == null || nameScope === 'locale'))
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { CountField, CountsResource } from '@/lib/counts'
|
|||||||
|
|
||||||
export type CountsAxis = { field: string; grain?: string }
|
export type CountsAxis = { field: string; grain?: string }
|
||||||
export type CountsCondition = {
|
export type CountsCondition = {
|
||||||
field: string; operator: string; value?: string; end?: string; blank?: boolean
|
field: string; operator: string; grain?: string; value?: string; end?: string; blank?: boolean
|
||||||
input?: 'single' | 'multiple' | 'list'; values?: (string | number | boolean)[] }
|
input?: 'single' | 'multiple' | 'list'; values?: (string | number | boolean)[] }
|
||||||
export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum'
|
export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum'
|
||||||
| 'json' | 'boolean' | 'interval'
|
| 'json' | 'boolean' | 'interval'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Public URL grammar: space-delimited tokens and indexed scalar fields, never JSON.
|
// Public URL grammar: space-delimited tokens and indexed scalar fields, never JSON.
|
||||||
export type QueryRecord = Record<string, string | number | boolean | null | undefined>
|
export type QueryRecord = Record<string, string | number | boolean | null | undefined>
|
||||||
const schemas: Record<string, string[]> = {
|
const schemas: Record<string, string[]> = {
|
||||||
conditions: ['field', 'operator', 'value', 'end', 'blank', 'input', 'values'],
|
conditions: ['field', 'operator', 'value', 'end', 'blank', 'input', 'values', 'grain', 'value_type'],
|
||||||
condition_values: ['condition', 'value', 'value_type'],
|
condition_values: ['condition', 'value', 'value_type'],
|
||||||
drill: ['field', 'grain', 'value', 'value_type'],
|
drill: ['field', 'grain', 'value', 'value_type'],
|
||||||
tag_refs: ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
|
tag_refs: ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
|
||||||
@@ -54,7 +54,8 @@ export const setQueryRecords = (
|
|||||||
if (!schemas[key]?.includes (field)) continue
|
if (!schemas[key]?.includes (field)) continue
|
||||||
const name = `${key}.${index}.${field}`
|
const name = `${key}.${index}.${field}`
|
||||||
if (field === 'value'
|
if (field === 'value'
|
||||||
&& ['drill', 'measures', 'column_order', 'condition_values'].includes (key))
|
&& (['drill', 'measures', 'column_order', 'condition_values'].includes (key)
|
||||||
|
|| (key === 'conditions' && record.operator === 'axis_eq')))
|
||||||
params.set (`${key}.${index}.value_type`, value == null ? 'null' : typeof value)
|
params.set (`${key}.${index}.value_type`, value == null ? 'null' : typeof value)
|
||||||
if (value == null) continue
|
if (value == null) continue
|
||||||
params.set (name, typeof value === 'boolean'
|
params.set (name, typeof value === 'boolean'
|
||||||
@@ -71,7 +72,8 @@ export const readCountState = <T,> (
|
|||||||
key: string,
|
key: string,
|
||||||
fallback: T,
|
fallback: T,
|
||||||
): T => {
|
): T => {
|
||||||
if (!params.has (key)) return fallback
|
if (!params.has (key) && ![...params.keys ()].some (name =>
|
||||||
|
name.startsWith (`${key}.`))) return fallback
|
||||||
if (key === 'rows' || key === 'cols')
|
if (key === 'rows' || key === 'cols')
|
||||||
return queryList (params.get (key)).map (token => {
|
return queryList (params.get (key)).map (token => {
|
||||||
const [field, grain] = token.split (':')
|
const [field, grain] = token.split (':')
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする