From 87dbe13c2483776c8f27d9622ece95e68a6755b3 Mon Sep 17 00:00:00 2001 From: alpaca-tc Date: Thu, 6 Jun 2024 23:58:15 +0900 Subject: [PATCH 01/13] like rails stringify --- frontend/utils/queryString.ts | 46 +++++++++++++++---- .../utils/queryString/__tests__/index.test.ts | 23 ++++++++++ frontend/utils/queryString/index.ts | 9 ++++ 3 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 frontend/utils/queryString/__tests__/index.test.ts create mode 100644 frontend/utils/queryString/index.ts diff --git a/frontend/utils/queryString.ts b/frontend/utils/queryString.ts index 4ee79db..7370da1 100644 --- a/frontend/utils/queryString.ts +++ b/frontend/utils/queryString.ts @@ -1,9 +1,39 @@ -export const stringify = (params: Record): string => - Object.entries(params) - .map(([key, value]) => { - if (Array.isArray(value)) { - return value.map((v) => `${key}[]=${v}`).join('&') - } - return `${key}=${value}` +import { isArray } from "util" + +const isSkipValue = (value: any): boolean => { + return ( + value === null || + value === undefined || + (typeof value === 'object' && Object.keys(value).length === 0) + ) +} + +const internalStringify = (key: string, value: any): Array<[string, string]> => { + const entries: Array<[string, string]> = [] + + if (isSkipValue(value)) { + return entries + } else if (Array.isArray(value)) { + value.forEach((v) => { + entries.push(...internalStringify(`${key}[]`, v)) }) - .join('&') + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => { + entries.push(...internalStringify(`${key}[${k}]`, v)) + }) + } else { + entries.push([key, value]) + } + + return entries +} + +export const stringify = (params: Record): string => { + const entries: Array<[string, string]> = [] + + Object.entries(params).forEach(([key, value]) => { + entries.push(...internalStringify(key, value)) + }) + + return entries.map(([key, value]) => `${key}=${value}`).join('&') +} diff --git a/frontend/utils/queryString/__tests__/index.test.ts b/frontend/utils/queryString/__tests__/index.test.ts new file mode 100644 index 0000000..263c3e3 --- /dev/null +++ b/frontend/utils/queryString/__tests__/index.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' + +import { stringify } from '..' + +describe('stringify', () => { + it('converts simple values to params', () => { + expect(stringify({ a: 1 })).toEqual('a=1') + expect(stringify({ a: '1' })).toEqual('a=1') + expect(stringify({ a: null })).toEqual('') + expect(stringify({ a: undefined })).toEqual('') + expect(stringify({ a: 1, b: 2 })).toEqual('a=1&b=2') + }) + + it('converts object values to params', () => { + expect(stringify({ a: {} })).toEqual('') + expect(stringify({ a: [] })).toEqual('') + expect(stringify({ a: [1] })).toEqual('a[]=1') + expect(stringify({ a: [1, 2] })).toEqual('a[]=1&a[]=2') + expect(stringify({ a: { b: 1 } })).toEqual('a[b]=1') + expect(stringify({ a: [{ b: 1 }] })).toEqual('a[][b]=1') + expect(stringify({ a: { b: { c: [1] } } })).toEqual('a[b][c][]=1') + }) +}) diff --git a/frontend/utils/queryString/index.ts b/frontend/utils/queryString/index.ts new file mode 100644 index 0000000..4ee79db --- /dev/null +++ b/frontend/utils/queryString/index.ts @@ -0,0 +1,9 @@ +export const stringify = (params: Record): string => + Object.entries(params) + .map(([key, value]) => { + if (Array.isArray(value)) { + return value.map((v) => `${key}[]=${v}`).join('&') + } + return `${key}=${value}` + }) + .join('&') From a11827ebd9bceb07e3c8c66b997a253b738872d2 Mon Sep 17 00:00:00 2001 From: alpaca-tc Date: Thu, 6 Jun 2024 23:58:54 +0900 Subject: [PATCH 02/13] Filter sources/dependencies by modules --- lib/diver_down/web.rb | 5 +- lib/diver_down/web/action.rb | 8 +- lib/diver_down/web/module_sources_filter.rb | 57 ++++++++++++++ .../web/module_sources_filter_spec.rb | 76 +++++++++++++++++++ 4 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 lib/diver_down/web/module_sources_filter.rb create mode 100644 spec/diver_down/web/module_sources_filter_spec.rb diff --git a/lib/diver_down/web.rb b/lib/diver_down/web.rb index d5bce2a..b3d3032 100644 --- a/lib/diver_down/web.rb +++ b/lib/diver_down/web.rb @@ -16,6 +16,7 @@ class Web require 'diver_down/web/definition_store' require 'diver_down/web/definition_loader' require 'diver_down/web/source_alias_resolver' + require 'diver_down/web/module_sources_filter' # For development autoload :DevServerMiddleware, 'diver_down/web/dev_server_middleware' @@ -61,7 +62,9 @@ def call(env) compound = request.params['compound'] == '1' concentrate = request.params['concentrate'] == '1' only_module = request.params['only_module'] == '1' - action.combine_definitions(bit_id, compound, concentrate, only_module) + allowed_modules = request.params['allowed_modules'] # Array>? + pp request.params + action.combine_definitions(bit_id, compound, concentrate, only_module, allowed_modules) in ['GET', %r{\A/api/sources/(?[^/]+)\.json\z}] source = Regexp.last_match[:source] action.source(source) diff --git a/lib/diver_down/web/action.rb b/lib/diver_down/web/action.rb index e5569f7..810007e 100644 --- a/lib/diver_down/web/action.rb +++ b/lib/diver_down/web/action.rb @@ -206,7 +206,8 @@ def pid # @param compound [Boolean] # @param concentrate [Boolean] # @param only_module [Boolean] - def combine_definitions(bit_id, compound, concentrate, only_module) + # @param allowed_modules [Array] + def combine_definitions(bit_id, compound, concentrate, only_module, allowed_modules) ids = DiverDown::Web::BitId.bit_id_to_ids(bit_id) valid_ids = ids.select do @@ -229,6 +230,9 @@ def combine_definitions(bit_id, compound, concentrate, only_module) # Resolve source aliases resolved_definition = @source_alias_resolver.resolve(definition) + # Filter all sources and dependencies by modules if allowed_modules is given + resolved_definition = DiverDown::Web::ModuleSourcesFilter.new(@metadata).filter(resolved_definition, match_modules: allowed_modules) unless allowed_modules.empty? + definition_to_dot = DiverDown::Web::DefinitionToDot.new(resolved_definition, @metadata, compound:, concentrate:, only_module:) json( @@ -236,7 +240,7 @@ def combine_definitions(bit_id, compound, concentrate, only_module) bit_id: DiverDown::Web::BitId.ids_to_bit_id(valid_ids).to_s, dot: definition_to_dot.to_s, dot_metadata: definition_to_dot.dot_metadata, - sources: definition.sources.map do + sources: resolved_definition.sources.map do { source_name: _1.source_name, resolved_alias: @metadata.source_alias.resolve_alias(_1.source_name), diff --git a/lib/diver_down/web/module_sources_filter.rb b/lib/diver_down/web/module_sources_filter.rb new file mode 100644 index 0000000..ff89114 --- /dev/null +++ b/lib/diver_down/web/module_sources_filter.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module DiverDown + class Web + class ModuleSourcesFilter + # @param metadata_alias [DiverDown::Web::Metadata] + def initialize(metadata) + @metadata = metadata + end + + # @param definition [DiverDown::Definition] + # @param match_modules [Array>] + # @return [DiverDown::Definition] + def filter(definition, match_modules:) + new_definition = DiverDown::Definition.new( + definition_group: definition.definition_group, + title: definition.title + ) + + is_match_modules = ->(source_name) do + source_modules = @metadata.source(source_name).modules + + match_modules.any? do |modules| + source_modules.first(modules.size) == modules + end + end + + definition.sources.each do |source| + next unless is_match_modules.call(source.source_name) + + new_source = new_definition.find_or_build_source(source.source_name) + + source.dependencies.each do |dependency| + next unless is_match_modules.call(dependency.source_name) + + new_dependency = new_source.find_or_build_dependency(dependency.source_name) + + next unless new_dependency + + dependency.method_ids.each do |method_id| + new_method_id = new_dependency.find_or_build_method_id( + context: method_id.context, + name: method_id.name + ) + + method_id.paths.each do |path| + new_method_id.add_path(path) + end + end + end + end + + new_definition + end + end + end +end diff --git a/spec/diver_down/web/module_sources_filter_spec.rb b/spec/diver_down/web/module_sources_filter_spec.rb new file mode 100644 index 0000000..c5de3e1 --- /dev/null +++ b/spec/diver_down/web/module_sources_filter_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +RSpec.describe DiverDown::Web::ModuleSourcesFilter do + describe 'InstanceMethods' do + describe '#resolve' do + include DefinitionHelper + + let(:metadata) { DiverDown::Web::Metadata.new(Tempfile.new(['test', '.yaml']).path) } + + it 'filters sources and dependencies by modules' do + definition = DiverDown::Definition.from_hash( + sources: [ + { + source_name: 'Employee', + dependencies: [ + { + source_name: 'User', + method_ids: [ + { + context: 'class', + name: 'call', + paths: [ + 'user.rb:1', + ], + }, + ], + }, { + source_name: 'BankAccount', + method_ids: [ + { + context: 'class', + name: 'call', + paths: [ + 'user.rb:1', + ], + }, + ], + }, + ], + }, { + source_name: 'Billing', + }, + ] + ) + + instance = described_class.new(metadata) + + metadata.source('Employee').modules = ['global'] + metadata.source('User').modules = ['global'] + new_definition = instance.filter(definition, match_modules: [['global']]) + + expect(new_definition.to_h).to eq(fill_default( + sources: [ + { + source_name: 'Employee', + dependencies: [ + { + source_name: 'User', + method_ids: [ + { + context: 'class', + name: 'call', + paths: [ + 'user.rb:1', + ], + }, + ], + }, + ], + }, + ] + )) + end + end + end +end From 00384aa88591a1dea22ccbd42bd493415632aece Mon Sep 17 00:00:00 2001 From: alpaca-tc Date: Fri, 7 Jun 2024 00:39:50 +0900 Subject: [PATCH 03/13] fix: broken graph --- lib/diver_down/web/definition_to_dot.rb | 90 +++++++++++-------- spec/diver_down/web/definition_to_dot_spec.rb | 90 +++++++++++++++++++ 2 files changed, 141 insertions(+), 39 deletions(-) diff --git a/lib/diver_down/web/definition_to_dot.rb b/lib/diver_down/web/definition_to_dot.rb index d4c1ad3..b79b237 100644 --- a/lib/diver_down/web/definition_to_dot.rb +++ b/lib/diver_down/web/definition_to_dot.rb @@ -198,7 +198,7 @@ def render_only_modules end # Remove duplicated prefix modules - # from [["A"], ["A", "B"]] to [["A", "B"]] + # from [["A"], ["A", "B"], ["A", "C"], ["D"]] to { "A" => { "B" => {}, "C" => {} }, "D" => {} } uniq_modules = [*dependency_map.keys, *dependency_map.values.map(&:keys).flatten(1)].uniq uniq_modules.reject! do |modules| modules.empty? || @@ -268,60 +268,50 @@ def render_only_modules end def render_sources + # Hash{ modules => sources } + # Hash{ Array => Array } by_modules = definition.sources.group_by do |source| metadata.source(source.source_name).modules end - # Remove duplicated prefix modules - # from [["A"], ["A", "B"]] to [["A", "B"]] - uniq_modules = by_modules.keys.uniq - uniq_modules = uniq_modules.reject do |modules| - uniq_modules.any? { _1[0..modules.size - 1] == modules && _1.length > modules.size } + # Render subgraph for each module and its sources second + nested_modules = array_to_hash(by_modules.keys.uniq.sort) + render_nested_modules_sources(by_modules, nested_modules) + + # Render dependencies last + definition.sources.sort_by(&:source_name).each do |source| + insert_dependencies(source) end + end - uniq_modules.each do |full_modules| - # Render module and source - if full_modules.empty? - sources = by_modules[full_modules].sort_by(&:source_name) + def render_nested_modules_sources(by_modules, nested_modules, prefix = []) + nested_modules.each do |module_name, next_nested_modules| + module_names = prefix + [module_name].compact + sources = (by_modules[module_names] || []).sort_by(&:source_name) + if module_name.nil? sources.each do |source| - insert_source(source) + io.puts build_source_node(source) end else - buf = swap_io do - indexes = (0..(full_modules.length - 1)).to_a - - chain_yield(indexes) do |index, next_proc| - module_names = full_modules[0..index] - module_name = module_names[-1] - - io.puts %(subgraph "#{escape_quote(module_label(module_names))}" {) - io.indented do - io.puts %(id="#{@dot_metadata_store.issue_modules_id(module_names)}") - io.puts %(label="#{escape_quote(module_name)}") - - sources = (by_modules[module_names] || []).sort_by(&:source_name) - sources.each do |source| - insert_source(source) - end - - next_proc&.call - end - io.puts '}' + io.puts %(subgraph "#{escape_quote(module_label(module_names))}" {) + io.indented do + io.puts %(id="#{@dot_metadata_store.issue_modules_id(module_names)}") + io.puts %(label="#{escape_quote(module_name)}") + + sources.each do |source| + io.puts build_source_node(source) end - end - io.write buf.string + render_nested_modules_sources(by_modules, next_nested_modules, module_names) + end + io.puts '}' end end - - definition.sources.sort_by(&:source_name).each do |source| - insert_dependencies(source) - end end - def insert_source(source) - io.puts %("#{escape_quote(source.source_name)}" #{build_attributes(label: source.source_name, id: @dot_metadata_store.issue_source_id(source))}) + def build_source_node(source) + %("#{escape_quote(source.source_name)}" #{build_attributes(label: source.source_name, id: @dot_metadata_store.issue_source_id(source))}) end def insert_dependencies(source) @@ -420,6 +410,28 @@ def module_label(*modules) def escape_quote(string) string.to_s.gsub(/"/, '\"') end + + # from [["A"], ["A", "B"], ["A", "C"], ["D"]] to { "A" => { "B" => {}, "C" => {} }, "D" => {} } + def array_to_hash(array) + hash = {} + array.each do |sub_array| + current_hash = hash + + if sub_array.empty? + current_hash[nil] = {} + else + sub_array.each_with_index do |element, index| + if index == sub_array.length - 1 + current_hash[element] = {} + else + current_hash[element] ||= {} + current_hash = current_hash[element] + end + end + end + end + hash + end end end end diff --git a/spec/diver_down/web/definition_to_dot_spec.rb b/spec/diver_down/web/definition_to_dot_spec.rb index 02fa5fe..9f6980d 100644 --- a/spec/diver_down/web/definition_to_dot_spec.rb +++ b/spec/diver_down/web/definition_to_dot_spec.rb @@ -196,6 +196,96 @@ def build_definition(title: 'title', sources: []) ) end + it 'returns digraph given multiple modules' do + definition = build_definition( + sources: [ + { + source_name: 'a.rb', + dependencies: [ + { + source_name: 'b.rb', + method_ids: [ + { + name: 'call_b', + context: 'class', + paths: [], + }, + ], + }, { + source_name: 'c.rb', + method_ids: [ + { + name: 'call_c', + context: 'class', + paths: [], + }, + ], + }, { + source_name: 'd.rb', + method_ids: [ + { + name: 'call_d', + context: 'class', + paths: [], + }, + ], + }, + ], + }, { + source_name: 'b.rb', + }, { + source_name: 'c.rb', + }, { + source_name: 'd.rb', + }, { + source_name: 'e.rb', + }, { + source_name: 'f.rb', + }, + ] + ) + + metadata.source('a.rb').modules = ['A'] + metadata.source('b.rb').modules = ['B'] + metadata.source('c.rb').modules = ['B'] + metadata.source('d.rb').modules = ['B', 'C'] + metadata.source('e.rb').modules = ['B', 'D'] + metadata.source('f.rb').modules = [] + metadata.source('unknown.rb').modules = ['Unknown'] + + instance = described_class.new(definition, metadata) + + expect(instance.to_s).to eq(<<~DOT) + strict digraph "title" { + "f.rb" [label="f.rb" id="graph_1"] + subgraph "cluster_A" { + id="graph_2" + label="A" + "a.rb" [label="a.rb" id="graph_3"] + } + subgraph "cluster_B" { + id="graph_4" + label="B" + "b.rb" [label="b.rb" id="graph_5"] + "c.rb" [label="c.rb" id="graph_6"] + subgraph "cluster_B::C" { + id="graph_7" + label="C" + "d.rb" [label="d.rb" id="graph_8"] + } + subgraph "cluster_B::D" { + id="graph_9" + label="D" + "e.rb" [label="e.rb" id="graph_10"] + } + } + "a.rb" -> "b.rb" [id="graph_11"] + "a.rb" -> "c.rb" [id="graph_12"] + "a.rb" -> "d.rb" [id="graph_13"] + } + DOT + end + it 'returns compound digraph if compound = true' do definition = build_definition( sources: [ From abf811166ce744f5d6b5ea3a17d6bb5734052f22 Mon Sep 17 00:00:00 2001 From: alpaca-tc Date: Fri, 7 Jun 2024 00:40:39 +0900 Subject: [PATCH 04/13] Add match_modules --- lib/diver_down/web.rb | 5 ++--- lib/diver_down/web/action.rb | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/diver_down/web.rb b/lib/diver_down/web.rb index b3d3032..8b3be3c 100644 --- a/lib/diver_down/web.rb +++ b/lib/diver_down/web.rb @@ -62,9 +62,8 @@ def call(env) compound = request.params['compound'] == '1' concentrate = request.params['concentrate'] == '1' only_module = request.params['only_module'] == '1' - allowed_modules = request.params['allowed_modules'] # Array>? - pp request.params - action.combine_definitions(bit_id, compound, concentrate, only_module, allowed_modules) + match_modules = request.params['match_modules'] || [] # Array> + action.combine_definitions(bit_id, compound, concentrate, only_module, match_modules) in ['GET', %r{\A/api/sources/(?[^/]+)\.json\z}] source = Regexp.last_match[:source] action.source(source) diff --git a/lib/diver_down/web/action.rb b/lib/diver_down/web/action.rb index 810007e..4a44141 100644 --- a/lib/diver_down/web/action.rb +++ b/lib/diver_down/web/action.rb @@ -206,8 +206,8 @@ def pid # @param compound [Boolean] # @param concentrate [Boolean] # @param only_module [Boolean] - # @param allowed_modules [Array] - def combine_definitions(bit_id, compound, concentrate, only_module, allowed_modules) + # @param match_modules [Array] + def combine_definitions(bit_id, compound, concentrate, only_module, match_modules) ids = DiverDown::Web::BitId.bit_id_to_ids(bit_id) valid_ids = ids.select do @@ -230,8 +230,8 @@ def combine_definitions(bit_id, compound, concentrate, only_module, allowed_modu # Resolve source aliases resolved_definition = @source_alias_resolver.resolve(definition) - # Filter all sources and dependencies by modules if allowed_modules is given - resolved_definition = DiverDown::Web::ModuleSourcesFilter.new(@metadata).filter(resolved_definition, match_modules: allowed_modules) unless allowed_modules.empty? + # Filter all sources and dependencies by modules if match_modules is given + resolved_definition = DiverDown::Web::ModuleSourcesFilter.new(@metadata).filter(resolved_definition, match_modules:) unless match_modules.empty? definition_to_dot = DiverDown::Web::DefinitionToDot.new(resolved_definition, @metadata, compound:, concentrate:, only_module:) From c8cd9eaff6c505517de3fe6f66742bf45f245703 Mon Sep 17 00:00:00 2001 From: alpaca-tc Date: Fri, 7 Jun 2024 14:08:30 +0900 Subject: [PATCH 05/13] Add rack-style query parser --- frontend/utils/queryString.ts | 39 ---- .../utils/queryString/__tests__/index.test.ts | 23 ++- frontend/utils/queryString/index.ts | 175 +++++++++++++++++- 3 files changed, 189 insertions(+), 48 deletions(-) delete mode 100644 frontend/utils/queryString.ts diff --git a/frontend/utils/queryString.ts b/frontend/utils/queryString.ts deleted file mode 100644 index 7370da1..0000000 --- a/frontend/utils/queryString.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { isArray } from "util" - -const isSkipValue = (value: any): boolean => { - return ( - value === null || - value === undefined || - (typeof value === 'object' && Object.keys(value).length === 0) - ) -} - -const internalStringify = (key: string, value: any): Array<[string, string]> => { - const entries: Array<[string, string]> = [] - - if (isSkipValue(value)) { - return entries - } else if (Array.isArray(value)) { - value.forEach((v) => { - entries.push(...internalStringify(`${key}[]`, v)) - }) - } else if (typeof value === 'object') { - Object.entries(value).forEach(([k, v]) => { - entries.push(...internalStringify(`${key}[${k}]`, v)) - }) - } else { - entries.push([key, value]) - } - - return entries -} - -export const stringify = (params: Record): string => { - const entries: Array<[string, string]> = [] - - Object.entries(params).forEach(([key, value]) => { - entries.push(...internalStringify(key, value)) - }) - - return entries.map(([key, value]) => `${key}=${value}`).join('&') -} diff --git a/frontend/utils/queryString/__tests__/index.test.ts b/frontend/utils/queryString/__tests__/index.test.ts index 263c3e3..ee4ce12 100644 --- a/frontend/utils/queryString/__tests__/index.test.ts +++ b/frontend/utils/queryString/__tests__/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { stringify } from '..' +import { stringify, parse } from '..' describe('stringify', () => { it('converts simple values to params', () => { @@ -21,3 +21,24 @@ describe('stringify', () => { expect(stringify({ a: { b: { c: [1] } } })).toEqual('a[b][c][]=1') }) }) + +describe('parse', () => { + it('parses simple values to params', () => { + expect(parse('a=1')).toEqual({ a: '1' }) + expect(parse('a=1')).toEqual({ a: '1' }) + expect(parse('a=')).toEqual({ a: '' }) + expect(parse('')).toEqual({}) + expect(parse('a=1&b=2')).toEqual({ a: '1', b: '2' }) + }) + + it('parses object values to params', () => { + expect(parse('a[]=1')).toEqual({ a: ['1'] }) + expect(parse('a[]=1&a[]=2')).toEqual({ a: ['1', '2'] }) + expect(parse('a[b]=1')).toEqual({ a: { b: '1' } }) + expect(parse('a[][b]=1')).toEqual({ a: [{ b: '1' }] }) + expect(parse('a[][b][c]=1')).toEqual({ a: [{ b: { c: '1' } }] }) + expect(parse('a[][b][c]=1&a[][b][d]=2')).toEqual({ a: [{ b: { c: '1', d: '2' } }] }) + expect(parse('a[][b][][c]=1')).toEqual({ a: [{ b: [{ c: '1' }] }] }) + expect(parse('a[b][c][]=1')).toEqual({ a: { b: { c: ['1'] } } }) + }) +}) diff --git a/frontend/utils/queryString/index.ts b/frontend/utils/queryString/index.ts index 4ee79db..fa0ecc5 100644 --- a/frontend/utils/queryString/index.ts +++ b/frontend/utils/queryString/index.ts @@ -1,9 +1,168 @@ -export const stringify = (params: Record): string => - Object.entries(params) - .map(([key, value]) => { - if (Array.isArray(value)) { - return value.map((v) => `${key}[]=${v}`).join('&') - } - return `${key}=${value}` +const isSkipValue = (value: any): boolean => { + return value === null || value === undefined || (typeof value === 'object' && Object.keys(value).length === 0) +} + +const isObject = (value: any): value is Record => { + return typeof value === 'object' && !Array.isArray(value) +} + +const internalStringify = (key: string, value: any): Array<[string, string]> => { + const entries: Array<[string, string]> = [] + + if (isSkipValue(value)) { + return entries + } else if (Array.isArray(value)) { + value.forEach((v) => { + entries.push(...internalStringify(`${key}[]`, v)) }) - .join('&') + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => { + entries.push(...internalStringify(`${key}[${k}]`, v)) + }) + } else { + entries.push([key, value]) + } + + return entries +} + +export const stringify = (params: Record): string => { + const entries: Array<[string, string]> = [] + + Object.entries(params).forEach(([key, value]) => { + entries.push(...internalStringify(key, value)) + }) + + return entries.map(([key, value]) => `${key}=${value}`).join('&') +} + +const paramDepthLimit = 32 + +class QueryStringParserError extends Error { } +class ParameterTypeError extends QueryStringParserError { } +class ParamsTooDeepError extends QueryStringParserError { } + +const normalizeParams = (params: Record, name: string, v: any, depth: number = 0): Record => { + if (depth >= paramDepthLimit) { + throw new ParamsTooDeepError() + } + + let k: string + let after: string + let start: string + + if (!name) { + // nil name, treat same as empty string (required by tests) + k = after = '' + } else if (depth === 0) { + // Start of parsing, don't treat [] or [ at start of string specially + const start = name.indexOf('[', 1) + if (start !== -1) { + // Start of parameter nesting, use part before brackets as key + k = name.slice(0, start) + after = name.slice(start) + } else { + // Plain parameter with no nesting + k = name + after = '' + } + } else if (name.startsWith('[]')) { + // Array nesting + k = '[]' + after = name.slice(2) + } else if (name.startsWith('[') && (start = name.indexOf(']', 1)) !== -1) { + // Hash nesting, use the part inside brackets as the key + k = name.slice(1, start) + after = name.slice(start + 1) + } else { + // Probably malformed input, nested but not starting with [ + // treat full name as key for backwards compatibility. + k = name + after = '' + } + + if (k === '') { + return params + } + + if (after === '') { + if (k === '[]' && depth !== 0) { + return [v] + } else { + params[k] = v + } + } else if (after === '[') { + params[name] = v + } else if (after === '[]') { + params[k] ??= [] + if (!Array.isArray(params[k])) { + throw new ParameterTypeError(`expected Array (got ${typeof params[k]}) for param '${k}'`) + } + params[k].push(v) + } else if (after.startsWith('[]')) { + // Recognize x[][y] (hash inside array) parameters + let childKey = '' + if ( + !( + after[2] === '[' && + after.endsWith(']') && + (childKey = after.slice(3, 3 + after.length - 4)) && + !childKey.includes('[') && + !childKey.includes(']') + ) + ) { + // Handle other nested array parameters + childKey = after.slice(2) + } + params[k] ??= [] + if (!Array.isArray(params[k])) { + throw new ParameterTypeError(`expected Array (got ${typeof params[k]}) for param '${k}'`) + } + + const last = params[k][params[k].length - 1] + if (isObject(last) && !paramsHashHasKey(params[k].slice(-1)[0], childKey)) { + normalizeParams(last, childKey, v, depth + 1) + } else { + params[k].push(normalizeParams({}, childKey, v, depth + 1)) + } + } else { + params[k] ??= {} + if (!isObject(params[k])) { + throw new ParameterTypeError(`expected object (got ${typeof params[k]}) for param '${k}'`) + } + params[k] = normalizeParams(params[k], after, v, depth + 1) + } + + return params +} + +const paramsHashHasKey = (hash: { [key: string]: any }, key: string): boolean => { + if (/\[\]/.test(key)) { + return false + } + + const parts = key.split(/[\[\]]+/) + let currentHash = hash + + for (const part of parts) { + if (part === '') { + continue + } + if (!isObject(currentHash) || !currentHash.hasOwnProperty(part)) { + return false + } + + currentHash = currentHash[part] + } + + return true +} + +export const parse = (queryString: string): Record => { + return [...new URLSearchParams(queryString).entries()].reduce( + (obj, [key, value]) => { + return normalizeParams(obj, key, value, 0) + }, + {} as Record, + ) +} From fb70a99296b8a5987cd7557c0f5c2a62b76e3149 Mon Sep 17 00:00:00 2001 From: alpaca-tc Date: Fri, 7 Jun 2024 16:09:18 +0900 Subject: [PATCH 06/13] wip --- frontend/hooks/useMatchModules.ts | 63 +++++++++++++++++++ frontend/pages/DefinitionList/Show.tsx | 8 ++- .../combinedDefinitionRepository.ts | 9 ++- 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 frontend/hooks/useMatchModules.ts diff --git a/frontend/hooks/useMatchModules.ts b/frontend/hooks/useMatchModules.ts new file mode 100644 index 0000000..3232507 --- /dev/null +++ b/frontend/hooks/useMatchModules.ts @@ -0,0 +1,63 @@ +import { parse, stringify } from '@/utils/queryString' +import { useEffect, useRef, useState } from 'react' +import { useSearchParams } from 'react-router-dom' + +export const KEY = 'match_modules' + +const encode = (matchModules: string[][]): Record => { + const searchParams = new URLSearchParams(stringify({ [KEY]: matchModules })) + const entries: { [key: string]: string } = {} + + const keys = [...searchParams.keys()] + keys.forEach((key: string) => { + const value = searchParams.get(key) + + if (typeof value === 'string') { + entries[key] = value + } + }) + + return entries +} + +const isNestedArrayString = (value: any): value is string[][] => { + return (Array.isArray(value) && value.every((v) => Array.isArray(v) && v.every((s) => typeof s === 'string'))) +} + +const decode = (searchParams: URLSearchParams): string[][] => { + const string = searchParams.toString() + const params = parse(string) + + if (isNestedArrayString(params[KEY])) { + return params[KEY] + } else { + return [] + } +} + +export const useMatchModules = () => { + const [matchModules, setMatchModules] = useState([]) + const initialized = useRef(false) + const [searchParams, setSearchParams] = useSearchParams() + + // Load ids on load + useEffect(() => { + if (!initialized.current) { + try { + setMatchModules(decode(searchParams)) + } catch (e) { + setSearchParams((prev) => ({ ...prev, [KEY]: '' })) + } + + initialized.current = true + } + }, [initialized, setMatchModules, searchParams, setSearchParams]) + + useEffect(() => { + if (!initialized.current) return + + setSearchParams((prev) => ({ ...prev, ...encode(matchModules) })) + }, [matchModules, setSearchParams]) + + return [matchModules, setMatchModules] as const +} diff --git a/frontend/pages/DefinitionList/Show.tsx b/frontend/pages/DefinitionList/Show.tsx index 5bb0e34..b48aeb1 100644 --- a/frontend/pages/DefinitionList/Show.tsx +++ b/frontend/pages/DefinitionList/Show.tsx @@ -22,7 +22,13 @@ export const Show: React.FC = () => { data: combinedDefinition, isLoading, mutate: mutateCombinedDefinition, - } = useCombinedDefinition(selectedDefinitionIds, graphOptions.compound, graphOptions.concentrate, graphOptions.onlyModule) + } = useCombinedDefinition( + selectedDefinitionIds, + graphOptions.compound, + graphOptions.concentrate, + graphOptions.onlyModule, + [], + ) const [recentModules, setRecentModules] = useState([]) return ( diff --git a/frontend/repositories/combinedDefinitionRepository.ts b/frontend/repositories/combinedDefinitionRepository.ts index 0c8d3fe..e874164 100644 --- a/frontend/repositories/combinedDefinitionRepository.ts +++ b/frontend/repositories/combinedDefinitionRepository.ts @@ -114,11 +114,18 @@ const fetchDefinitionShow = async (requestPath: string): Promise (value ? '1' : null) -export const useCombinedDefinition = (ids: number[], compound: boolean, concentrate: boolean, onlyModule: boolean) => { +export const useCombinedDefinition = ( + ids: number[], + compound: boolean, + concentrate: boolean, + onlyModule: boolean, + match_modules: string[][], +) => { const params = { compound: toBooleanFlag(compound), concentrate: toBooleanFlag(concentrate), only_module: toBooleanFlag(onlyModule), + match_modules, } const requestPath = `${path.api.definitions.show(ids)}?${stringify(params)}` const shouldFetch = ids.length > 0 From bc57509bb1ff838baf64901d0ddd32cf10ac5df0 Mon Sep 17 00:00:00 2001 From: alpaca-tc Date: Fri, 7 Jun 2024 18:04:36 +0900 Subject: [PATCH 07/13] wip --- frontend/components/Layout/Header.tsx | 2 +- frontend/constants/path.ts | 3 + frontend/hooks/useMatchModules.ts | 63 --------- frontend/main.tsx | 2 + frontend/pages/ModuleDefinitions/Show.tsx | 118 ++++++++++++++++ frontend/pages/ModuleDefinitions/index.ts | 1 + frontend/pages/Modules/Show.tsx | 161 ++++++++++++---------- 7 files changed, 211 insertions(+), 139 deletions(-) delete mode 100644 frontend/hooks/useMatchModules.ts create mode 100644 frontend/pages/ModuleDefinitions/Show.tsx create mode 100644 frontend/pages/ModuleDefinitions/index.ts diff --git a/frontend/components/Layout/Header.tsx b/frontend/components/Layout/Header.tsx index ff93d9f..9575d8d 100644 --- a/frontend/components/Layout/Header.tsx +++ b/frontend/components/Layout/Header.tsx @@ -34,7 +34,7 @@ export const Header: React.FC = () => { }, { children: 'Module List', - current: pathname === path.modules.index() || /^\/modules\//.test(pathname), + current: pathname === path.modules.index() || /^\/modules\//.test(pathname) || /^\/module_definitions\//.test(pathname), href: path.modules.index(), onClick: () => navigate(path.modules.index()), }, diff --git a/frontend/constants/path.ts b/frontend/constants/path.ts index 71a668e..4e4f674 100644 --- a/frontend/constants/path.ts +++ b/frontend/constants/path.ts @@ -16,6 +16,9 @@ export const path = { index: () => '/modules', show: (moduleNames: string[]) => `/modules/${moduleNames.join('/')}`, }, + moduleDefinitions: { + show: (moduleNames: string[]) => `/module_definitions/${moduleNames.join('/')}`, + }, licenses: { index: () => '/licenses', }, diff --git a/frontend/hooks/useMatchModules.ts b/frontend/hooks/useMatchModules.ts deleted file mode 100644 index 3232507..0000000 --- a/frontend/hooks/useMatchModules.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { parse, stringify } from '@/utils/queryString' -import { useEffect, useRef, useState } from 'react' -import { useSearchParams } from 'react-router-dom' - -export const KEY = 'match_modules' - -const encode = (matchModules: string[][]): Record => { - const searchParams = new URLSearchParams(stringify({ [KEY]: matchModules })) - const entries: { [key: string]: string } = {} - - const keys = [...searchParams.keys()] - keys.forEach((key: string) => { - const value = searchParams.get(key) - - if (typeof value === 'string') { - entries[key] = value - } - }) - - return entries -} - -const isNestedArrayString = (value: any): value is string[][] => { - return (Array.isArray(value) && value.every((v) => Array.isArray(v) && v.every((s) => typeof s === 'string'))) -} - -const decode = (searchParams: URLSearchParams): string[][] => { - const string = searchParams.toString() - const params = parse(string) - - if (isNestedArrayString(params[KEY])) { - return params[KEY] - } else { - return [] - } -} - -export const useMatchModules = () => { - const [matchModules, setMatchModules] = useState([]) - const initialized = useRef(false) - const [searchParams, setSearchParams] = useSearchParams() - - // Load ids on load - useEffect(() => { - if (!initialized.current) { - try { - setMatchModules(decode(searchParams)) - } catch (e) { - setSearchParams((prev) => ({ ...prev, [KEY]: '' })) - } - - initialized.current = true - } - }, [initialized, setMatchModules, searchParams, setSearchParams]) - - useEffect(() => { - if (!initialized.current) return - - setSearchParams((prev) => ({ ...prev, ...encode(matchModules) })) - }, [matchModules, setSearchParams]) - - return [matchModules, setMatchModules] as const -} diff --git a/frontend/main.tsx b/frontend/main.tsx index 47cd404..6c78179 100644 --- a/frontend/main.tsx +++ b/frontend/main.tsx @@ -6,6 +6,7 @@ import { Layout } from './components/Layout' import { path } from './constants/path' import { NotFound } from './pages/Errors' import { Show as DefinitionList } from './pages/DefinitionList' +import { Show as ModuleDefinitionShow } from './pages/ModuleDefinitions' import { Index as LicenseIndex } from './pages/Lincense' import { Index as ModuleIndex, Show as ModuleShow } from './pages/Modules' import { Index as SourceIndex, Show as SourceShow } from './pages/Sources' @@ -23,6 +24,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> } /> diff --git a/frontend/pages/ModuleDefinitions/Show.tsx b/frontend/pages/ModuleDefinitions/Show.tsx new file mode 100644 index 0000000..3343e7d --- /dev/null +++ b/frontend/pages/ModuleDefinitions/Show.tsx @@ -0,0 +1,118 @@ +import React, { useCallback, useMemo, useState } from 'react' +import styled from 'styled-components' + +import { Loading } from '@/components/Loading' +import { Aside, Section, Sidebar, Stack } from '@/components/ui' +import { color } from '@/constants/theme' +import { useBitIdHash } from '@/hooks/useBitIdHash' +import { useCombinedDefinition } from '@/repositories/combinedDefinitionRepository' + +import { RecentModulesContext } from '@/context/RecentModulesContext' +import { Module } from '@/models/module' +import { useGraphOptions } from '@/hooks/useGraphOptions' +import { DefinitionGraph } from '@/components/DefinitionGraph' +import { DefinitionSources } from '@/components/DefinitionSources' +import { useParams } from 'react-router-dom' +import { useModule } from '@/repositories/moduleRepository' + +export const Show: React.FC = () => { + const pathModules = (useParams()['*'] ?? '').split('/') + const { data: specificModule } = useModule(pathModules) + + const relatedDefinitionIds = useMemo(() => { + if (specificModule) { + return specificModule.relatedDefinitions.map(({ id }) => id) + } else { + return [] + } + }, [specificModule]) + + const [graphOptions, setGraphOptions] = useGraphOptions() + const { + data: combinedDefinition, + isLoading: isLoadingCombinedDefinition, + mutate: mutateCombinedDefinition, + } = useCombinedDefinition( + relatedDefinitionIds, + graphOptions.compound, + graphOptions.concentrate, + graphOptions.onlyModule, + [pathModules], + ) + const [recentModules, setRecentModules] = useState([]) + + return ( + + + + + {isLoadingCombinedDefinition ? ( + + + + ) : !combinedDefinition ? ( + +

No data

+
+ ) : ( + + + + + )} +
+
+
+
+ ) +} + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + height: calc(100% - 1px); /* 100% - padding-top of layout */ + width: 100vw; +` + +const StyledSidebar = styled(Sidebar)` + display: flex; + height: 100%; +` + +const StyledAside = styled(Aside)` + box-sizing: border-box; + border-top: 1px solid ${color.BORDER}; + border-right: 1px solid ${color.BORDER}; + background-color: ${color.WHITE}; + height: inherit; +` + +const StyledSection = styled(Section)` + box-sizing: border-box; + height: inherit; +` + +const CenterStack = styled(Stack)` + display: flex; + flex-direction: row; + height: inherit; + justify-content: center; +` + +const StyledStack = styled(Stack)` + display: flex; + flex-direction: row; + height: inherit; +` + +const StyledDefinitionSources = styled(DefinitionSources)` + flex: 1; +` diff --git a/frontend/pages/ModuleDefinitions/index.ts b/frontend/pages/ModuleDefinitions/index.ts new file mode 100644 index 0000000..65b3a74 --- /dev/null +++ b/frontend/pages/ModuleDefinitions/index.ts @@ -0,0 +1 @@ +export * from './Show' diff --git a/frontend/pages/Modules/Show.tsx b/frontend/pages/Modules/Show.tsx index 438ed7c..b11cd11 100644 --- a/frontend/pages/Modules/Show.tsx +++ b/frontend/pages/Modules/Show.tsx @@ -41,82 +41,93 @@ export const Show: React.FC = () => {
- {specificModule && !isLoading ? ( - -
- - Sources -
- - - - - - - - {specificModule.sources.length === 0 ? ( - - no sources - - ) : ( - - {specificModule.sources.map((source) => ( - - - - - ))} - - )} -
Source NameMemo
- {source.sourceName} - - {source.memo} -
-
-
-
+ +
+ + Links + + Graph + + +
-
- - - Related Definitions - - Select All - - -
- - - - - - - {specificModule.relatedDefinitions.length === 0 ? ( - - no related definitions - - ) : ( - - {specificModule.relatedDefinitions.map((relatedDefinition) => ( - - - - ))} - - )} -
Title
- - {relatedDefinition.title} - -
-
-
-
-
- ) : ( - - )} + {specificModule && !isLoading ? ( + <> +
+ + Sources +
+ + + + + + + + {specificModule.sources.length === 0 ? ( + + no sources + + ) : ( + + {specificModule.sources.map((source) => ( + + + + + ))} + + )} +
Source NameMemo
+ {source.sourceName} + + {source.memo} +
+
+
+
+ +
+ + + Related Definitions + + Select All + + +
+ + + + + + + {specificModule.relatedDefinitions.length === 0 ? ( + + no related definitions + + ) : ( + + {specificModule.relatedDefinitions.map((relatedDefinition) => ( + + + + ))} + + )} +
Title
+ + {relatedDefinition.title} + +
+
+
+
+ + ) : ( + + )} +
From 844b9293a9b06f1c8ad2704dfda9ad84bf732df7 Mon Sep 17 00:00:00 2001 From: alpaca-tc Date: Fri, 7 Jun 2024 23:16:22 +0900 Subject: [PATCH 08/13] Freeze to avoid modifying definitions. --- lib/diver_down/definition.rb | 8 ++++ lib/diver_down/definition/dependency.rb | 10 +++++ lib/diver_down/definition/method_id.rb | 6 +++ lib/diver_down/definition/source.rb | 13 +++++++ lib/diver_down/web/definition_store.rb | 1 + spec/diver_down/definition/dependency_spec.rb | 14 +++++++ spec/diver_down/definition/method_id_spec.rb | 10 +++++ spec/diver_down/definition/source_spec.rb | 29 ++++++++++++++ spec/diver_down/definition_spec.rb | 39 +++++++++++++++++++ spec/diver_down/web/definition_store_spec.rb | 1 + 10 files changed, 131 insertions(+) diff --git a/lib/diver_down/definition.rb b/lib/diver_down/definition.rb index 0572057..9f6198c 100644 --- a/lib/diver_down/definition.rb +++ b/lib/diver_down/definition.rb @@ -103,5 +103,13 @@ def hash [self.class, definition_group, title, sources].hash end end + + # @return [void] + def freeze + super + + @source_map.transform_values(&:freeze) + @source_map.freeze + end end end diff --git a/lib/diver_down/definition/dependency.rb b/lib/diver_down/definition/dependency.rb index 51df756..7a33e4c 100644 --- a/lib/diver_down/definition/dependency.rb +++ b/lib/diver_down/definition/dependency.rb @@ -102,6 +102,16 @@ def hash def inspect %(#<#{self.class} source_name="#{source_name}" method_ids=#{method_ids}>") end + + # @return [void] + def freeze + super + @method_id_map.transform_values do + _1.transform_values(&:freeze) + _1.freeze + end + @method_id_map.freeze + end end end end diff --git a/lib/diver_down/definition/method_id.rb b/lib/diver_down/definition/method_id.rb index f92a7aa..d4bc126 100644 --- a/lib/diver_down/definition/method_id.rb +++ b/lib/diver_down/definition/method_id.rb @@ -78,6 +78,12 @@ def ==(other) def inspect %(#<#{self.class} #{human_method_name} paths=#{paths.inspect}>") end + + # @return [void] + def freeze + super + @paths.freeze + end end end end diff --git a/lib/diver_down/definition/source.rb b/lib/diver_down/definition/source.rb index 261d12c..e9422af 100644 --- a/lib/diver_down/definition/source.rb +++ b/lib/diver_down/definition/source.rb @@ -52,6 +52,12 @@ def dependency(dependency_source_name) @dependency_map[dependency_source_name] end + # @param dependency_source_name [String] + # @return [void] + def delete_dependency(dependency_source_name) + @dependency_map.delete(dependency_source_name) + end + # @return [Array] def dependencies @dependency_map.values.sort @@ -85,6 +91,13 @@ def ==(other) def hash [self.class, source_name, dependencies].hash end + + # @return [void] + def freeze + super + @dependency_map.transform_values(&:freeze) + @dependency_map.freeze + end end end end diff --git a/lib/diver_down/web/definition_store.rb b/lib/diver_down/web/definition_store.rb index 0b4b1dd..c55ce53 100644 --- a/lib/diver_down/web/definition_store.rb +++ b/lib/diver_down/web/definition_store.rb @@ -31,6 +31,7 @@ def set(*definitions) raise(ArgumentError, 'definition already set') if _1.store_id _1.store_id = @definitions.size + 1 + _1.freeze @definitions.push(_1) @definition_group_store[_1.definition_group] << _1 diff --git a/spec/diver_down/definition/dependency_spec.rb b/spec/diver_down/definition/dependency_spec.rb index 48033d0..8e28038 100644 --- a/spec/diver_down/definition/dependency_spec.rb +++ b/spec/diver_down/definition/dependency_spec.rb @@ -231,5 +231,19 @@ expect(array.sort.map(&:source_name)).to eq(%w[a.rb b.rb c.rb]) end end + + describe '#freeze' do + it 'freezes instance' do + dependency = described_class.new(source_name: 'a.rb') + method_id = dependency.find_or_build_method_id(name: 'to_s', context: 'class') + + expect(dependency).to_not be_frozen + dependency.freeze + expect(dependency).to be_frozen + + expect { dependency.find_or_build_method_id(name: 'unknown', context: 'class') }.to raise_error(FrozenError) + expect { method_id.add_path('a.rb') }.to raise_error(FrozenError) + end + end end end diff --git a/spec/diver_down/definition/method_id_spec.rb b/spec/diver_down/definition/method_id_spec.rb index 955a3e2..b43a0f7 100644 --- a/spec/diver_down/definition/method_id_spec.rb +++ b/spec/diver_down/definition/method_id_spec.rb @@ -94,5 +94,15 @@ ) end end + + describe '#freeze' do + it 'freezes instance' do + method_id = described_class.new(name: 'to_s', context: 'class') + method_id.freeze + + expect(method_id).to be_frozen + expect { method_id.add_path('a.rb:9') }.to raise_error(FrozenError) + end + end end end diff --git a/spec/diver_down/definition/source_spec.rb b/spec/diver_down/definition/source_spec.rb index 828675a..b12e362 100644 --- a/spec/diver_down/definition/source_spec.rb +++ b/spec/diver_down/definition/source_spec.rb @@ -155,6 +155,19 @@ end end + describe '#delete_dependency' do + it 'deletes dependency' do + source = described_class.new(source_name: 'a.rb') + dependency = source.find_or_build_dependency('b.rb') + + expect(source.dependency(dependency.source_name)).to eq(dependency) + + source.delete_dependency(dependency.source_name) + + expect(source.dependency(dependency.source_name)).to be_nil + end + end + describe '#<=>' do it 'compares sources' do sources = [ @@ -210,5 +223,21 @@ ) end end + + describe '#freeze' do + it 'freezes instance' do + source = described_class.new(source_name: 'a.rb') + dependency = source.find_or_build_dependency('b.rb') + method_id = dependency.find_or_build_method_id(name: 'to_s', context: 'class') + + expect(source).to_not be_frozen + source.freeze + expect(source).to be_frozen + + expect { source.find_or_build_dependency('c.rb') }.to raise_error(FrozenError) + expect { dependency.find_or_build_method_id(name: 'unknown', context: 'class') }.to raise_error(FrozenError) + expect { method_id.add_path('a.rb') }.to raise_error(FrozenError) + end + end end end diff --git a/spec/diver_down/definition_spec.rb b/spec/diver_down/definition_spec.rb index 0b88475..0949bea 100644 --- a/spec/diver_down/definition_spec.rb +++ b/spec/diver_down/definition_spec.rb @@ -199,5 +199,44 @@ expect(definition.hash).to_not eq(different_store_id.hash) end end + + describe '#freeze' do + it 'freezes instance' do + definition = described_class.new( + title: 'title', + sources: [ + DiverDown::Definition::Source.new( + source_name: 'a.rb', + dependencies: [ + DiverDown::Definition::Dependency.new( + source_name: 'b.rb', + method_ids: [ + DiverDown::Definition::MethodId.new( + name: 'A', + context: 'class', + paths: ['a.rb'] + ), + ] + ), + DiverDown::Definition::Dependency.new( + source_name: 'c.rb' + ), + ] + ), + ] + ) + + definition.freeze + + source = definition.source('a.rb') + dependency = source.dependency('b.rb') + method_id = dependency.find_or_build_method_id(name: 'A', context: 'class') + + expect { definition.find_or_build_source('unknown') }.to raise_error(FrozenError) + expect { source.find_or_build_dependency('unknown') }.to raise_error(FrozenError) + expect { dependency.find_or_build_method_id(name: 'unknown', context: 'class') }.to raise_error(FrozenError) + expect { method_id.add_path('a.rb:9') }.to raise_error(FrozenError) + end + end end end diff --git a/spec/diver_down/web/definition_store_spec.rb b/spec/diver_down/web/definition_store_spec.rb index 81ddabf..3f0e14f 100644 --- a/spec/diver_down/web/definition_store_spec.rb +++ b/spec/diver_down/web/definition_store_spec.rb @@ -28,6 +28,7 @@ definition_3 = DiverDown::Definition.new(title: 'c') ids = store.set(definition_1, definition_2, definition_3) expect(ids).to eq([1, 2, 3]) + expect([definition_1, definition_2, definition_3]).to all(be_frozen) end it 'returns id if definition already set' do From e43536b9a9064f270993f01c405e79703688d928 Mon Sep 17 00:00:00 2001 From: alpaca-tc Date: Fri, 7 Jun 2024 23:23:58 +0900 Subject: [PATCH 09/13] Reuse combined_definition --- lib/diver_down/web/definition_store.rb | 19 +++++++++++++++++++ spec/diver_down/web/definition_store_spec.rb | 12 ++++++++++++ 2 files changed, 31 insertions(+) diff --git a/lib/diver_down/web/definition_store.rb b/lib/diver_down/web/definition_store.rb index c55ce53..5bc38e5 100644 --- a/lib/diver_down/web/definition_store.rb +++ b/lib/diver_down/web/definition_store.rb @@ -11,6 +11,7 @@ def initialize # Hash{ Integer(unique bit flag) => DiverDown::Definition } @definitions = [] @definition_group_store = Hash.new { |h, k| h[k] = [] } + @combined_definition = nil end # @param id [Integer] @@ -36,10 +37,28 @@ def set(*definitions) @definitions.push(_1) @definition_group_store[_1.definition_group] << _1 + # Reset combined_definition + @combined_definition = nil + _1.store_id end end + # @return [DiverDown::Definition] + def combined_definition + if @combined_definition.nil? + @combined_definition = DiverDown::Definition.combine( + definition_group: nil, + title: 'All Definitions', + definitions: @definitions + ) + + @combined_definition.freeze + end + + @combined_definition + end + # @return [Array] def definition_groups keys = @definition_group_store.keys diff --git a/spec/diver_down/web/definition_store_spec.rb b/spec/diver_down/web/definition_store_spec.rb index 3f0e14f..5137b87 100644 --- a/spec/diver_down/web/definition_store_spec.rb +++ b/spec/diver_down/web/definition_store_spec.rb @@ -40,6 +40,18 @@ end end + describe '#combined_definition' do + it 'returns combined_definition' do + store = described_class.new + definition_1 = DiverDown::Definition.new(title: 'a') + definition_2 = DiverDown::Definition.new(title: 'b') + definition_3 = DiverDown::Definition.new(title: 'c') + store.set(definition_1, definition_2, definition_3) + + expect(store.combined_definition).to be_a(DiverDown::Definition) + end + end + describe '#definition_groups' do it 'returns definition_groups' do store = described_class.new From f5221a02dd4c88a291926dcde448a97a73f2439e Mon Sep 17 00:00:00 2001 From: alpaca-tc Date: Fri, 7 Jun 2024 23:46:14 +0900 Subject: [PATCH 10/13] stringify boolean --- frontend/utils/queryString/__tests__/index.test.ts | 1 + frontend/utils/queryString/index.ts | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/utils/queryString/__tests__/index.test.ts b/frontend/utils/queryString/__tests__/index.test.ts index ee4ce12..c316075 100644 --- a/frontend/utils/queryString/__tests__/index.test.ts +++ b/frontend/utils/queryString/__tests__/index.test.ts @@ -9,6 +9,7 @@ describe('stringify', () => { expect(stringify({ a: null })).toEqual('') expect(stringify({ a: undefined })).toEqual('') expect(stringify({ a: 1, b: 2 })).toEqual('a=1&b=2') + expect(stringify({ a: true })).toEqual('a=1') }) it('converts object values to params', () => { diff --git a/frontend/utils/queryString/index.ts b/frontend/utils/queryString/index.ts index fa0ecc5..5c48ecd 100644 --- a/frontend/utils/queryString/index.ts +++ b/frontend/utils/queryString/index.ts @@ -26,11 +26,23 @@ const internalStringify = (key: string, value: any): Array<[string, string]> => return entries } +const stringifyValue = (value: any): any => { + if (typeof value === 'boolean') { + return value ? '1' : null + } else { + return value + } +} + export const stringify = (params: Record): string => { const entries: Array<[string, string]> = [] Object.entries(params).forEach(([key, value]) => { - entries.push(...internalStringify(key, value)) + const v = stringifyValue(value) + + if (v !== null && v !== undefined) { + entries.push(...internalStringify(key, v)) + } }) return entries.map(([key, value]) => `${key}=${value}`).join('&') From 03a9f81250d4b28ef120cb8c501e6c91c3500972 Mon Sep 17 00:00:00 2001 From: alpaca-tc Date: Sat, 8 Jun 2024 00:02:31 +0900 Subject: [PATCH 11/13] Filter by single modules --- lib/diver_down/web/module_sources_filter.rb | 9 +++------ spec/diver_down/web/module_sources_filter_spec.rb | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/diver_down/web/module_sources_filter.rb b/lib/diver_down/web/module_sources_filter.rb index ff89114..f7e2753 100644 --- a/lib/diver_down/web/module_sources_filter.rb +++ b/lib/diver_down/web/module_sources_filter.rb @@ -9,9 +9,9 @@ def initialize(metadata) end # @param definition [DiverDown::Definition] - # @param match_modules [Array>] + # @param modules [Array>] # @return [DiverDown::Definition] - def filter(definition, match_modules:) + def filter(definition, modules:) new_definition = DiverDown::Definition.new( definition_group: definition.definition_group, title: definition.title @@ -19,10 +19,7 @@ def filter(definition, match_modules:) is_match_modules = ->(source_name) do source_modules = @metadata.source(source_name).modules - - match_modules.any? do |modules| - source_modules.first(modules.size) == modules - end + source_modules.first(modules.size) == modules end definition.sources.each do |source| diff --git a/spec/diver_down/web/module_sources_filter_spec.rb b/spec/diver_down/web/module_sources_filter_spec.rb index c5de3e1..75c2358 100644 --- a/spec/diver_down/web/module_sources_filter_spec.rb +++ b/spec/diver_down/web/module_sources_filter_spec.rb @@ -47,7 +47,7 @@ metadata.source('Employee').modules = ['global'] metadata.source('User').modules = ['global'] - new_definition = instance.filter(definition, match_modules: [['global']]) + new_definition = instance.filter(definition, modules: ['global']) expect(new_definition.to_h).to eq(fill_default( sources: [ From 9fb87a0fce5acfbafea310e496576abaacf3cdc3 Mon Sep 17 00:00:00 2001 From: alpaca-tc Date: Sat, 8 Jun 2024 00:06:24 +0900 Subject: [PATCH 12/13] Add /module_definition/:module_names for module definition --- lib/diver_down/web.rb | 12 ++- lib/diver_down/web/action.rb | 77 +++++++++++++------ lib/diver_down/web/definition_store.rb | 2 - spec/diver_down/web/definition_to_dot_spec.rb | 2 +- spec/diver_down/web_spec.rb | 22 ++++++ 5 files changed, 88 insertions(+), 27 deletions(-) diff --git a/lib/diver_down/web.rb b/lib/diver_down/web.rb index 8b3be3c..917cbdc 100644 --- a/lib/diver_down/web.rb +++ b/lib/diver_down/web.rb @@ -57,13 +57,18 @@ def call(env) in ['GET', %r{\A/api/modules/(?.+)\.json\z}] module_names = CGI.unescape(Regexp.last_match[:module_names]).split('/') action.module(module_names) + in ['GET', %r{\A/api/module_definitions/(?.+)\.json\z}] + modules = CGI.unescape(Regexp.last_match[:modules]).split('/') + compound = request.params['compound'] == '1' + concentrate = request.params['concentrate'] == '1' + only_module = request.params['only_module'] == '1' + action.module_definition(compound, concentrate, only_module, modules) in ['GET', %r{\A/api/definitions/(?\d+)\.json\z}] bit_id = Regexp.last_match[:bit_id].to_i compound = request.params['compound'] == '1' concentrate = request.params['concentrate'] == '1' only_module = request.params['only_module'] == '1' - match_modules = request.params['match_modules'] || [] # Array> - action.combine_definitions(bit_id, compound, concentrate, only_module, match_modules) + action.combine_definitions(bit_id, compound, concentrate, only_module) in ['GET', %r{\A/api/sources/(?[^/]+)\.json\z}] source = Regexp.last_match[:source] action.source(source) @@ -111,6 +116,9 @@ def load_definition_files_on_thread(definition_files) # No needed to synchronize because this is executed on a single thread. @store.set(definition) end + + # Cache combined_definition + @store.combined_definition end end end diff --git a/lib/diver_down/web/action.rb b/lib/diver_down/web/action.rb index 4a44141..9f34207 100644 --- a/lib/diver_down/web/action.rb +++ b/lib/diver_down/web/action.rb @@ -200,14 +200,39 @@ def pid ) end + # GET /api/module_definition/:module_names.json + # + # @param bit_id [Integer] + # @param compound [Boolean] + # @param concentrate [Boolean] + # @param only_module [Boolean] + # @param modules [Array] + def module_definition(compound, concentrate, only_module, modules) + definition = @store.combined_definition + + # Filter all sources and dependencies by modules if match_modules is given + resolved_definition = DiverDown::Web::ModuleSourcesFilter.new(@metadata).filter(definition, modules:) + + # Resolve source aliases + resolved_definition = @source_alias_resolver.resolve(resolved_definition) + + render_combined_definition( + (1..@store.size).to_a, + resolved_definition, + modules, + compound:, + concentrate:, + only_module: + ) + end + # GET /api/definitions/:bit_id.json # # @param bit_id [Integer] # @param compound [Boolean] # @param concentrate [Boolean] # @param only_module [Boolean] - # @param match_modules [Array] - def combine_definitions(bit_id, compound, concentrate, only_module, match_modules) + def combine_definitions(bit_id, compound, concentrate, only_module) ids = DiverDown::Web::BitId.bit_id_to_ids(bit_id) valid_ids = ids.select do @@ -230,26 +255,13 @@ def combine_definitions(bit_id, compound, concentrate, only_module, match_module # Resolve source aliases resolved_definition = @source_alias_resolver.resolve(definition) - # Filter all sources and dependencies by modules if match_modules is given - resolved_definition = DiverDown::Web::ModuleSourcesFilter.new(@metadata).filter(resolved_definition, match_modules:) unless match_modules.empty? - - definition_to_dot = DiverDown::Web::DefinitionToDot.new(resolved_definition, @metadata, compound:, concentrate:, only_module:) - - json( - titles:, - bit_id: DiverDown::Web::BitId.ids_to_bit_id(valid_ids).to_s, - dot: definition_to_dot.to_s, - dot_metadata: definition_to_dot.dot_metadata, - sources: resolved_definition.sources.map do - { - source_name: _1.source_name, - resolved_alias: @metadata.source_alias.resolve_alias(_1.source_name), - memo: @metadata.source(_1.source_name).memo, - modules: @metadata.source(_1.source_name).modules.map do |module_name| - { module_name: } - end, - } - end + render_combined_definition( + valid_ids, + resolved_definition, + titles, + compound:, + concentrate:, + only_module: ) else not_found @@ -425,6 +437,27 @@ def json(data, status = 200) def json_error(message, status = 422) json({ message: }, status) end + + def render_combined_definition(ids, definition, titles, compound:, concentrate:, only_module:) + definition_to_dot = DiverDown::Web::DefinitionToDot.new(definition, @metadata, compound:, concentrate:, only_module:) + + json( + titles:, + bit_id: DiverDown::Web::BitId.ids_to_bit_id(ids).to_s, + dot: definition_to_dot.to_s, + dot_metadata: definition_to_dot.dot_metadata, + sources: definition.sources.map do + { + source_name: _1.source_name, + resolved_alias: @metadata.source_alias.resolve_alias(_1.source_name), + memo: @metadata.source(_1.source_name).memo, + modules: @metadata.source(_1.source_name).modules.map do |module_name| + { module_name: } + end, + } + end + ) + end end end end diff --git a/lib/diver_down/web/definition_store.rb b/lib/diver_down/web/definition_store.rb index 5bc38e5..e870d3b 100644 --- a/lib/diver_down/web/definition_store.rb +++ b/lib/diver_down/web/definition_store.rb @@ -5,8 +5,6 @@ class Web class DefinitionStore include Enumerable - attr_reader :bit_id - def initialize # Hash{ Integer(unique bit flag) => DiverDown::Definition } @definitions = [] diff --git a/spec/diver_down/web/definition_to_dot_spec.rb b/spec/diver_down/web/definition_to_dot_spec.rb index 9f6980d..09b0571 100644 --- a/spec/diver_down/web/definition_to_dot_spec.rb +++ b/spec/diver_down/web/definition_to_dot_spec.rb @@ -283,7 +283,7 @@ def build_definition(title: 'title', sources: []) "a.rb" -> "c.rb" [id="graph_12"] "a.rb" -> "d.rb" [id="graph_13"] } - DOT + DOT end it 'returns compound digraph if compound = true' do diff --git a/spec/diver_down/web_spec.rb b/spec/diver_down/web_spec.rb index 429d914..7dc6704 100644 --- a/spec/diver_down/web_spec.rb +++ b/spec/diver_down/web_spec.rb @@ -617,6 +617,28 @@ def assert_definition_group(definition_group, expected_ids) end end + describe 'GET /api/module_definitions/:module_names+.json' do + it 'returns combined_definition' do + definition = DiverDown::Definition.new( + title: 'title', + sources: [ + DiverDown::Definition::Source.new( + source_name: 'a.rb' + ), + ] + ) + store.set(definition) + + metadata.source('a.rb').modules = ['A'] + + get '/api/module_definitions/A.json' + + expect(last_response.status).to eq(200) + expect(last_response.headers['content-type']).to eq('application/json') + expect(last_response.body).to include('digraph') + end + end + describe 'GET /api/sources/:source.json' do it 'returns 404 if source is not found' do get '/api/sources/unknown.json' From 64800c1520a77ec3f703aa9beff3c09f5b6f7128 Mon Sep 17 00:00:00 2001 From: alpaca-tc Date: Mon, 17 Jun 2024 14:06:36 +0900 Subject: [PATCH 13/13] Render graph for module --- frontend/constants/path.ts | 3 ++ frontend/models/combinedDefinition.ts | 6 ++++ frontend/pages/DefinitionList/Show.tsx | 8 +---- frontend/pages/ModuleDefinitions/Show.tsx | 21 ++----------- frontend/pages/Modules/Show.tsx | 4 +-- .../combinedDefinitionRepository.ts | 30 ++++++++----------- .../moduleDefinitionRepository.ts | 12 ++++++++ frontend/utils/queryString/index.ts | 6 ++-- 8 files changed, 41 insertions(+), 49 deletions(-) create mode 100644 frontend/repositories/moduleDefinitionRepository.ts diff --git a/frontend/constants/path.ts b/frontend/constants/path.ts index 4e4f674..f206e55 100644 --- a/frontend/constants/path.ts +++ b/frontend/constants/path.ts @@ -47,5 +47,8 @@ export const path = { index: () => '/api/modules.json', show: (moduleNames: string[]) => `/api/modules/${moduleNames.join('/')}.json`, }, + moduleDefinitions: { + show: (moduleNames: string[]) => `/api/module_definitions/${moduleNames.join('/')}.json`, + }, }, } diff --git a/frontend/models/combinedDefinition.ts b/frontend/models/combinedDefinition.ts index 741eb10..6b850e3 100644 --- a/frontend/models/combinedDefinition.ts +++ b/frontend/models/combinedDefinition.ts @@ -1,6 +1,12 @@ import { Module } from './module' import { Source } from './source' +export type CombinedDefinitionOptions = { + compound: boolean + concentrate: boolean + onlyModule: boolean +} + type BaseDotMetadata = { id: string } diff --git a/frontend/pages/DefinitionList/Show.tsx b/frontend/pages/DefinitionList/Show.tsx index b48aeb1..913321e 100644 --- a/frontend/pages/DefinitionList/Show.tsx +++ b/frontend/pages/DefinitionList/Show.tsx @@ -22,13 +22,7 @@ export const Show: React.FC = () => { data: combinedDefinition, isLoading, mutate: mutateCombinedDefinition, - } = useCombinedDefinition( - selectedDefinitionIds, - graphOptions.compound, - graphOptions.concentrate, - graphOptions.onlyModule, - [], - ) + } = useCombinedDefinition(selectedDefinitionIds, graphOptions) const [recentModules, setRecentModules] = useState([]) return ( diff --git a/frontend/pages/ModuleDefinitions/Show.tsx b/frontend/pages/ModuleDefinitions/Show.tsx index 3343e7d..d999723 100644 --- a/frontend/pages/ModuleDefinitions/Show.tsx +++ b/frontend/pages/ModuleDefinitions/Show.tsx @@ -4,8 +4,6 @@ import styled from 'styled-components' import { Loading } from '@/components/Loading' import { Aside, Section, Sidebar, Stack } from '@/components/ui' import { color } from '@/constants/theme' -import { useBitIdHash } from '@/hooks/useBitIdHash' -import { useCombinedDefinition } from '@/repositories/combinedDefinitionRepository' import { RecentModulesContext } from '@/context/RecentModulesContext' import { Module } from '@/models/module' @@ -13,32 +11,17 @@ import { useGraphOptions } from '@/hooks/useGraphOptions' import { DefinitionGraph } from '@/components/DefinitionGraph' import { DefinitionSources } from '@/components/DefinitionSources' import { useParams } from 'react-router-dom' -import { useModule } from '@/repositories/moduleRepository' +import { useModuleDefinition } from '@/repositories/moduleDefinitionRepository' export const Show: React.FC = () => { const pathModules = (useParams()['*'] ?? '').split('/') - const { data: specificModule } = useModule(pathModules) - - const relatedDefinitionIds = useMemo(() => { - if (specificModule) { - return specificModule.relatedDefinitions.map(({ id }) => id) - } else { - return [] - } - }, [specificModule]) const [graphOptions, setGraphOptions] = useGraphOptions() const { data: combinedDefinition, isLoading: isLoadingCombinedDefinition, mutate: mutateCombinedDefinition, - } = useCombinedDefinition( - relatedDefinitionIds, - graphOptions.compound, - graphOptions.concentrate, - graphOptions.onlyModule, - [pathModules], - ) + } = useModuleDefinition(pathModules, graphOptions) const [recentModules, setRecentModules] = useState([]) return ( diff --git a/frontend/pages/Modules/Show.tsx b/frontend/pages/Modules/Show.tsx index b11cd11..a553708 100644 --- a/frontend/pages/Modules/Show.tsx +++ b/frontend/pages/Modules/Show.tsx @@ -45,9 +45,7 @@ export const Show: React.FC = () => {
Links - - Graph - + Graph
diff --git a/frontend/repositories/combinedDefinitionRepository.ts b/frontend/repositories/combinedDefinitionRepository.ts index e874164..c1b380c 100644 --- a/frontend/repositories/combinedDefinitionRepository.ts +++ b/frontend/repositories/combinedDefinitionRepository.ts @@ -1,7 +1,7 @@ import useSWR from 'swr' import { path } from '@/constants/path' -import { CombinedDefinition, DotMetadata } from '@/models/combinedDefinition' +import { CombinedDefinition, CombinedDefinitionGraphOptions, DotMetadata } from '@/models/combinedDefinition' import { bitIdToIds } from '@/utils/bitId' import { stringify } from '@/utils/queryString' @@ -93,7 +93,7 @@ const parseDotMetadata = (metadata: DotMetadataResponse): DotMetadata => { } } -const fetchDefinitionShow = async (requestPath: string): Promise => { +export const fetchCombinedDefinition = async (requestPath: string): Promise => { const response = await get(requestPath) return { @@ -112,24 +112,20 @@ const fetchDefinitionShow = async (requestPath: string): Promise (value ? '1' : null) - -export const useCombinedDefinition = ( - ids: number[], - compound: boolean, - concentrate: boolean, - onlyModule: boolean, - match_modules: string[][], -) => { +export const stringifyCombinedDefinitionOptions = (graphOptions: CombinedDefinitionGraphOptions): string => { const params = { - compound: toBooleanFlag(compound), - concentrate: toBooleanFlag(concentrate), - only_module: toBooleanFlag(onlyModule), - match_modules, + compound: graphOptions.compound, + concentrate: graphOptions.concentrate, + only_module: graphOptions.onlyModule, } - const requestPath = `${path.api.definitions.show(ids)}?${stringify(params)}` + + return stringify(params) +} + +export const useCombinedDefinition = (ids: number[], graphOptions: CombinedDefinitionGraphOptions) => { + const requestPath = `${path.api.definitions.show(ids)}?${stringifyCombinedDefinitionOptions(graphOptions)}` const shouldFetch = ids.length > 0 - const { data, isLoading, mutate } = useSWR(shouldFetch ? requestPath : null, fetchDefinitionShow) + const { data, isLoading, mutate } = useSWR(shouldFetch ? requestPath : null, fetchCombinedDefinition) return { data, isLoading, mutate } } diff --git a/frontend/repositories/moduleDefinitionRepository.ts b/frontend/repositories/moduleDefinitionRepository.ts new file mode 100644 index 0000000..af55e56 --- /dev/null +++ b/frontend/repositories/moduleDefinitionRepository.ts @@ -0,0 +1,12 @@ +import { path } from '@/constants/path' +import { CombinedDefinitionGraphOptions } from '@/models/combinedDefinition' +import { fetchCombinedDefinition, stringifyCombinedDefinitionOptions } from './combinedDefinitionRepository' +import useSWR from 'swr' + +export const useModuleDefinition = (moduleNames: string[], graphOptions: CombinedDefinitionGraphOptions) => { + const requestPath = `${path.api.moduleDefinitions.show(moduleNames)}?${stringifyCombinedDefinitionOptions(graphOptions)}` + const shouldFetch = moduleNames.length > 0 + const { data, isLoading, mutate } = useSWR(shouldFetch ? requestPath : null, fetchCombinedDefinition) + + return { data, isLoading, mutate } +} diff --git a/frontend/utils/queryString/index.ts b/frontend/utils/queryString/index.ts index 5c48ecd..22fd7a9 100644 --- a/frontend/utils/queryString/index.ts +++ b/frontend/utils/queryString/index.ts @@ -50,9 +50,9 @@ export const stringify = (params: Record): string => { const paramDepthLimit = 32 -class QueryStringParserError extends Error { } -class ParameterTypeError extends QueryStringParserError { } -class ParamsTooDeepError extends QueryStringParserError { } +class QueryStringParserError extends Error {} +class ParameterTypeError extends QueryStringParserError {} +class ParamsTooDeepError extends QueryStringParserError {} const normalizeParams = (params: Record, name: string, v: any, depth: number = 0): Record => { if (depth >= paramDepthLimit) {