expressir 2.1.30 → 2.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/docs.yml +98 -0
- data/.github/workflows/links.yml +100 -0
- data/.github/workflows/rake.yml +4 -0
- data/.github/workflows/release.yml +5 -0
- data/.github/workflows/validate_schemas.yml +1 -1
- data/.gitignore +3 -0
- data/.rubocop.yml +1 -1
- data/.rubocop_todo.yml +244 -39
- data/Gemfile +2 -1
- data/README.adoc +621 -54
- data/docs/Gemfile +12 -0
- data/docs/_config.yml +141 -0
- data/docs/_guides/changes/changes-format.adoc +778 -0
- data/docs/_guides/changes/importing-eengine.adoc +898 -0
- data/docs/_guides/changes/index.adoc +396 -0
- data/docs/_guides/changes/programmatic-usage.adoc +1038 -0
- data/docs/_guides/changes/validating-changes.adoc +681 -0
- data/docs/_guides/cli/benchmark-performance.adoc +834 -0
- data/docs/_guides/cli/coverage-analysis.adoc +921 -0
- data/docs/_guides/cli/format-schemas.adoc +547 -0
- data/docs/_guides/cli/index.adoc +8 -0
- data/docs/_guides/cli/managing-changes.adoc +927 -0
- data/docs/_guides/cli/validate-ascii.adoc +645 -0
- data/docs/_guides/cli/validate-schemas.adoc +534 -0
- data/docs/_guides/index.adoc +165 -0
- data/docs/_guides/ler/creating-packages.adoc +664 -0
- data/docs/_guides/ler/index.adoc +305 -0
- data/docs/_guides/ler/loading-packages.adoc +707 -0
- data/docs/_guides/ler/package-formats.adoc +748 -0
- data/docs/_guides/ler/querying-packages.adoc +826 -0
- data/docs/_guides/ler/validating-packages.adoc +750 -0
- data/docs/_guides/liquid/basic-templates.adoc +813 -0
- data/docs/_guides/liquid/documentation-generation.adoc +1042 -0
- data/docs/_guides/liquid/drops-reference.adoc +829 -0
- data/docs/_guides/liquid/filters-and-tags.adoc +912 -0
- data/docs/_guides/liquid/index.adoc +468 -0
- data/docs/_guides/manifests/creating-manifests.adoc +483 -0
- data/docs/_guides/manifests/index.adoc +307 -0
- data/docs/_guides/manifests/resolving-manifests.adoc +557 -0
- data/docs/_guides/manifests/validating-manifests.adoc +713 -0
- data/docs/_guides/ruby-api/formatting-schemas.adoc +605 -0
- data/docs/_guides/ruby-api/index.adoc +257 -0
- data/docs/_guides/ruby-api/parsing-files.adoc +421 -0
- data/docs/_guides/ruby-api/search-engine.adoc +609 -0
- data/docs/_guides/ruby-api/working-with-repository.adoc +577 -0
- data/docs/_pages/data-model.adoc +665 -0
- data/docs/_pages/express-language.adoc +506 -0
- data/docs/_pages/getting-started.adoc +414 -0
- data/docs/_pages/index.adoc +116 -0
- data/docs/_pages/introduction.adoc +256 -0
- data/docs/_pages/ler-packages.adoc +837 -0
- data/docs/_pages/parsers.adoc +683 -0
- data/docs/_pages/schema-manifests.adoc +431 -0
- data/docs/_references/index.adoc +228 -0
- data/docs/_tutorials/creating-ler-package.adoc +735 -0
- data/docs/_tutorials/documentation-coverage.adoc +795 -0
- data/docs/_tutorials/index.adoc +221 -0
- data/docs/_tutorials/liquid-templates.adoc +806 -0
- data/docs/_tutorials/parsing-your-first-schema.adoc +522 -0
- data/docs/_tutorials/querying-schemas.adoc +751 -0
- data/docs/_tutorials/working-with-multiple-schemas.adoc +676 -0
- data/docs/index.adoc +242 -0
- data/docs/lychee.toml +84 -0
- data/examples/demo_ler_usage.sh +86 -0
- data/examples/ler/README.md +111 -0
- data/examples/ler/simple_example.ler +0 -0
- data/examples/ler/simple_schema.exp +33 -0
- data/examples/ler_build.rb +75 -0
- data/examples/ler_cli.rb +79 -0
- data/examples/ler_demo_complete.rb +276 -0
- data/examples/ler_query.rb +91 -0
- data/examples/ler_query_examples.rb +305 -0
- data/examples/ler_stats.rb +81 -0
- data/examples/phase3_demo.rb +159 -0
- data/examples/query_demo_simple.rb +131 -0
- data/expressir.gemspec +2 -0
- data/lib/expressir/cli.rb +12 -4
- data/lib/expressir/commands/manifest.rb +427 -0
- data/lib/expressir/commands/package.rb +1274 -0
- data/lib/expressir/commands/validate.rb +70 -37
- data/lib/expressir/commands/validate_ascii.rb +607 -0
- data/lib/expressir/commands/validate_load.rb +88 -0
- data/lib/expressir/express/formatter.rb +5 -1
- data/lib/expressir/express/formatters/remark_item_formatter.rb +25 -0
- data/lib/expressir/express/parser.rb +33 -0
- data/lib/expressir/manifest/resolver.rb +213 -0
- data/lib/expressir/manifest/validator.rb +195 -0
- data/lib/expressir/model/declarations/entity.rb +6 -0
- data/lib/expressir/model/dependency_resolver.rb +270 -0
- data/lib/expressir/model/indexes/entity_index.rb +103 -0
- data/lib/expressir/model/indexes/reference_index.rb +148 -0
- data/lib/expressir/model/indexes/type_index.rb +149 -0
- data/lib/expressir/model/interface_validator.rb +384 -0
- data/lib/expressir/model/repository.rb +400 -5
- data/lib/expressir/model/repository_validator.rb +295 -0
- data/lib/expressir/model/search_engine.rb +525 -0
- data/lib/expressir/model.rb +4 -94
- data/lib/expressir/package/builder.rb +200 -0
- data/lib/expressir/package/metadata.rb +81 -0
- data/lib/expressir/package/reader.rb +165 -0
- data/lib/expressir/schema_manifest.rb +11 -1
- data/lib/expressir/version.rb +1 -1
- data/lib/expressir.rb +15 -2
- metadata +114 -4
- data/docs/benchmarking.adoc +0 -107
- data/docs/liquid_drops.adoc +0 -1547
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Expressir
|
|
4
|
+
module Model
|
|
5
|
+
# Resolves dependencies between EXPRESS schemas
|
|
6
|
+
# Handles USE FROM and REFERENCE FROM interfaces
|
|
7
|
+
# Detects circular dependencies
|
|
8
|
+
class DependencyResolver
|
|
9
|
+
# Exception for circular dependencies
|
|
10
|
+
class CircularDependencyError < StandardError
|
|
11
|
+
attr_reader :dependency_chain
|
|
12
|
+
|
|
13
|
+
def initialize(message, chain)
|
|
14
|
+
super(message)
|
|
15
|
+
@dependency_chain = chain
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_reader :base_dirs, :schema_registry, :verbose, :unresolved
|
|
20
|
+
|
|
21
|
+
# Initialize resolver
|
|
22
|
+
# @param base_dirs [String, Array<String>] Base directories to search
|
|
23
|
+
# @param schema_registry [Hash] Manual schema name => path mappings
|
|
24
|
+
# @param verbose [Boolean] Enable verbose logging
|
|
25
|
+
def initialize(base_dirs: Dir.pwd, schema_registry: {}, verbose: false)
|
|
26
|
+
@base_dirs = Array(base_dirs).map { |d| File.expand_path(d) }
|
|
27
|
+
@schema_registry = schema_registry || {}
|
|
28
|
+
@verbose = verbose
|
|
29
|
+
@resolved_schemas = Set.new
|
|
30
|
+
@resolution_stack = []
|
|
31
|
+
@unresolved = []
|
|
32
|
+
@last_found_base_index = nil
|
|
33
|
+
@last_found_base_dir = nil
|
|
34
|
+
@multiple_matches = []
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Resolve all dependencies starting from root schema
|
|
38
|
+
# @param root_schema_path [String] Path to root schema file
|
|
39
|
+
# @return [Array<String>] Ordered list of schema file paths
|
|
40
|
+
def resolve_dependencies(root_schema_path)
|
|
41
|
+
@resolved_schemas.clear
|
|
42
|
+
@resolution_stack.clear
|
|
43
|
+
@unresolved.clear
|
|
44
|
+
|
|
45
|
+
resolve_recursive(File.expand_path(root_schema_path))
|
|
46
|
+
@resolved_schemas.to_a
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Extract interfaces from schema file using proper parsing
|
|
50
|
+
# @param schema_path [String] Path to schema file
|
|
51
|
+
# @return [Array<Hash>] List of interfaces with :kind, :schema_name
|
|
52
|
+
def extract_interfaces(schema_path)
|
|
53
|
+
require_relative "../express/parser"
|
|
54
|
+
|
|
55
|
+
interfaces = []
|
|
56
|
+
|
|
57
|
+
# Parse the schema file properly
|
|
58
|
+
repo = Expressir::Express::Parser.from_file(schema_path)
|
|
59
|
+
|
|
60
|
+
# Extract interfaces from first schema (file should contain one schema)
|
|
61
|
+
schema = repo.schemas.first
|
|
62
|
+
return interfaces unless schema&.interfaces
|
|
63
|
+
|
|
64
|
+
schema.interfaces.each do |interface|
|
|
65
|
+
kind = case interface.kind
|
|
66
|
+
when Expressir::Model::Declarations::Interface::USE
|
|
67
|
+
"USE"
|
|
68
|
+
when Expressir::Model::Declarations::Interface::REFERENCE
|
|
69
|
+
"REFERENCE"
|
|
70
|
+
else
|
|
71
|
+
interface.kind.first
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
interfaces << {
|
|
75
|
+
kind: kind,
|
|
76
|
+
schema_name: interface.schema.id,
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
interfaces
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Resolve schema location
|
|
84
|
+
# @param schema_name [String] Schema name to find
|
|
85
|
+
# @param kind [String] Interface kind ("USE" or "REFERENCE")
|
|
86
|
+
# @param current_path [String] Path of schema requesting the reference
|
|
87
|
+
# @return [String, nil] Path to schema file, or nil if not found
|
|
88
|
+
def resolve_schema_location(schema_name, _kind, _current_path)
|
|
89
|
+
# Check schema_registry first
|
|
90
|
+
if @schema_registry[schema_name]
|
|
91
|
+
registry_path = File.expand_path(@schema_registry[schema_name])
|
|
92
|
+
return registry_path if File.exist?(registry_path)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Search ONLY in base_dirs recursively and collect ALL matches
|
|
96
|
+
all_matches = []
|
|
97
|
+
@base_dirs.each_with_index do |base_dir, index|
|
|
98
|
+
candidate = find_schema_in_directory(schema_name, base_dir)
|
|
99
|
+
if candidate
|
|
100
|
+
all_matches << { path: candidate, base_dir: base_dir, index: index }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# If no matches found, return nil
|
|
105
|
+
return nil if all_matches.empty?
|
|
106
|
+
|
|
107
|
+
# If multiple matches found, record for warning
|
|
108
|
+
if all_matches.size > 1
|
|
109
|
+
@multiple_matches << {
|
|
110
|
+
schema_name: schema_name,
|
|
111
|
+
matches: all_matches,
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Use the first match
|
|
116
|
+
first_match = all_matches.first
|
|
117
|
+
@last_found_base_index = first_match[:index]
|
|
118
|
+
@last_found_base_dir = first_match[:base_dir]
|
|
119
|
+
|
|
120
|
+
first_match[:path]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Get resolution statistics
|
|
124
|
+
# @return [Hash] Statistics about resolved schemas
|
|
125
|
+
def statistics
|
|
126
|
+
{
|
|
127
|
+
total_schemas: @resolved_schemas.size,
|
|
128
|
+
schemas: @resolved_schemas.to_a,
|
|
129
|
+
base_dirs: @base_dirs,
|
|
130
|
+
multiple_matches: @multiple_matches,
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
# Resolve schema and its dependencies recursively
|
|
137
|
+
# @param schema_path [String] Path to schema file
|
|
138
|
+
# @return [void]
|
|
139
|
+
def resolve_recursive(schema_path)
|
|
140
|
+
return if @resolved_schemas.include?(schema_path)
|
|
141
|
+
|
|
142
|
+
# Check for circular dependency - schema-level circular references are valid in EXPRESS
|
|
143
|
+
if @resolution_stack.include?(schema_path)
|
|
144
|
+
# Schema is already being processed - this is a back-reference
|
|
145
|
+
# Skip re-processing; the schema will be added to resolved_schemas when its original pass completes
|
|
146
|
+
return
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
@resolution_stack.push(schema_path)
|
|
150
|
+
|
|
151
|
+
# Extract and resolve interfaces
|
|
152
|
+
interfaces = extract_interfaces(schema_path)
|
|
153
|
+
interfaces.each do |interface|
|
|
154
|
+
if @verbose
|
|
155
|
+
schema_name = File.basename(schema_path, ".*")
|
|
156
|
+
print " #{interface[:kind]} FROM #{interface[:schema_name]} (in #{schema_name}): "
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
dep_path = resolve_schema_location(
|
|
160
|
+
interface[:schema_name],
|
|
161
|
+
interface[:kind],
|
|
162
|
+
schema_path,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if dep_path
|
|
166
|
+
if @verbose
|
|
167
|
+
# Show which source and relative path
|
|
168
|
+
if @last_found_base_dir && @last_found_base_index
|
|
169
|
+
relative_path = dep_path.sub("#{@last_found_base_dir}/", "")
|
|
170
|
+
source_label = "[source #{@last_found_base_index + 1}]"
|
|
171
|
+
puts "\e[32m✓\e[0m #{source_label} #{relative_path}"
|
|
172
|
+
else
|
|
173
|
+
relative_path = File.basename(dep_path)
|
|
174
|
+
puts "\e[32m✓\e[0m #{relative_path}"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
resolve_recursive(dep_path)
|
|
178
|
+
else
|
|
179
|
+
# Track unresolved reference
|
|
180
|
+
@unresolved << {
|
|
181
|
+
from: schema_path,
|
|
182
|
+
kind: interface[:kind],
|
|
183
|
+
schema_name: interface[:schema_name],
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if @verbose
|
|
187
|
+
puts "\e[31m✗ not found\e[0m"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
@resolution_stack.pop
|
|
193
|
+
@resolved_schemas.add(schema_path)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Find schema file in directory
|
|
197
|
+
# @param schema_name [String] Schema name to find
|
|
198
|
+
# @param directory [String] Directory to search
|
|
199
|
+
# @param recursive [Boolean] Whether to search subdirectories
|
|
200
|
+
# @return [String, nil] Path to schema file or nil
|
|
201
|
+
def find_schema_in_directory(schema_name, directory)
|
|
202
|
+
return nil unless File.directory?(directory)
|
|
203
|
+
|
|
204
|
+
# Try direct match first (resource pattern: schema_name.exp)
|
|
205
|
+
candidate = File.join(directory, "#{schema_name}.exp")
|
|
206
|
+
return File.expand_path(candidate) if File.exist?(candidate)
|
|
207
|
+
|
|
208
|
+
# Try module pattern: {lower-case-prefix-without-_schema}/{arm|mim}.exp
|
|
209
|
+
# Examples:
|
|
210
|
+
# Activity_method_mim -> activity_method/mim.exp
|
|
211
|
+
# Activity_method_arm -> activity_method/arm.exp
|
|
212
|
+
module_path = try_module_pattern(schema_name, directory)
|
|
213
|
+
return module_path if module_path
|
|
214
|
+
|
|
215
|
+
# Search recursively in subdirectories
|
|
216
|
+
# Only in base_dirs to avoid permission errors in system directories
|
|
217
|
+
begin
|
|
218
|
+
# First try direct name match
|
|
219
|
+
Dir.glob(File.join(directory, "**",
|
|
220
|
+
"#{schema_name}.exp")).each do |file|
|
|
221
|
+
return File.expand_path(file)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Also try module pattern recursively for _arm/_mim schemas
|
|
225
|
+
if schema_name =~ /^(.+)_(arm|mim)$/i
|
|
226
|
+
prefix = Regexp.last_match(1)
|
|
227
|
+
suffix = Regexp.last_match(2).downcase
|
|
228
|
+
prefix = prefix.sub(/_schema$/i, "")
|
|
229
|
+
module_dir = prefix.downcase
|
|
230
|
+
|
|
231
|
+
# Search for **/module_dir/suffix.exp pattern
|
|
232
|
+
Dir.glob(File.join(directory, "**", module_dir,
|
|
233
|
+
"#{suffix}.exp")).each do |file|
|
|
234
|
+
return File.expand_path(file)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
rescue Errno::EACCES, Errno::EPERM
|
|
238
|
+
# Ignore permission errors when searching directories
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Try module naming pattern
|
|
246
|
+
# @param schema_name [String] Schema name (e.g., "Activity_method_mim")
|
|
247
|
+
# @param directory [String] Base directory
|
|
248
|
+
# @return [String, nil] Path if found, nil otherwise
|
|
249
|
+
def try_module_pattern(schema_name, directory)
|
|
250
|
+
# Check if schema name ends with _arm or _mim
|
|
251
|
+
if schema_name =~ /^(.+)_(arm|mim)$/i
|
|
252
|
+
prefix = Regexp.last_match(1)
|
|
253
|
+
suffix = Regexp.last_match(2).downcase
|
|
254
|
+
|
|
255
|
+
# Remove trailing _schema if present
|
|
256
|
+
prefix = prefix.sub(/_schema$/i, "")
|
|
257
|
+
|
|
258
|
+
# Convert to lowercase and replace underscores
|
|
259
|
+
module_dir = prefix.downcase
|
|
260
|
+
|
|
261
|
+
# Try: directory/module_dir/suffix.exp
|
|
262
|
+
candidate = File.join(directory, module_dir, "#{suffix}.exp")
|
|
263
|
+
return File.expand_path(candidate) if File.exist?(candidate)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Expressir
|
|
4
|
+
module Model
|
|
5
|
+
module Indexes
|
|
6
|
+
# Index for fast entity lookup across schemas
|
|
7
|
+
# Maintains indexes by qualified name and by schema for efficient querying
|
|
8
|
+
class EntityIndex
|
|
9
|
+
attr_reader :by_schema, :by_qualified_name
|
|
10
|
+
|
|
11
|
+
# Initialize a new entity index
|
|
12
|
+
# @param schemas [Array<Declarations::Schema>] Schemas to index
|
|
13
|
+
def initialize(schemas = [])
|
|
14
|
+
@by_schema = {}
|
|
15
|
+
@by_qualified_name = {}
|
|
16
|
+
build(schemas) unless schemas.empty?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Build indexes from schemas
|
|
20
|
+
# @param schemas [Array<Declarations::Schema>] Schemas to index
|
|
21
|
+
# @return [void]
|
|
22
|
+
def build(schemas)
|
|
23
|
+
@by_schema = {}
|
|
24
|
+
@by_qualified_name = {}
|
|
25
|
+
|
|
26
|
+
schemas.each do |schema|
|
|
27
|
+
schema_id = schema.id.safe_downcase
|
|
28
|
+
@by_schema[schema_id] = {}
|
|
29
|
+
|
|
30
|
+
next unless schema.entities
|
|
31
|
+
|
|
32
|
+
schema.entities.each do |entity|
|
|
33
|
+
entity_id = entity.id.safe_downcase
|
|
34
|
+
qualified_name = "#{schema_id}.#{entity_id}"
|
|
35
|
+
|
|
36
|
+
@by_schema[schema_id][entity_id] = entity
|
|
37
|
+
@by_qualified_name[qualified_name] = entity
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Find entity by qualified name
|
|
43
|
+
# @param qualified_name [String] Entity qualified name (e.g., "schema.entity")
|
|
44
|
+
# @return [Declarations::Entity, nil] Found entity or nil
|
|
45
|
+
def find(qualified_name)
|
|
46
|
+
return nil if qualified_name.nil? || qualified_name.empty?
|
|
47
|
+
|
|
48
|
+
normalized_name = qualified_name.safe_downcase
|
|
49
|
+
|
|
50
|
+
# Try qualified name first
|
|
51
|
+
entity = @by_qualified_name[normalized_name]
|
|
52
|
+
return entity if entity
|
|
53
|
+
|
|
54
|
+
# Try as simple name across all schemas
|
|
55
|
+
schema_name, entity_name = normalized_name.split(".", 2)
|
|
56
|
+
if entity_name.nil?
|
|
57
|
+
# Search all schemas for simple name
|
|
58
|
+
@by_schema.each_value do |entities|
|
|
59
|
+
entity = entities[schema_name]
|
|
60
|
+
return entity if entity
|
|
61
|
+
end
|
|
62
|
+
else
|
|
63
|
+
# Look in specific schema
|
|
64
|
+
schema_entities = @by_schema[schema_name]
|
|
65
|
+
return schema_entities[entity_name] if schema_entities
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# List all entities
|
|
72
|
+
# @param schema [String, nil] Filter by schema name
|
|
73
|
+
# @return [Array<Declarations::Entity>] List of entities
|
|
74
|
+
def list(schema: nil)
|
|
75
|
+
if schema
|
|
76
|
+
@by_schema[schema.safe_downcase]&.values || []
|
|
77
|
+
else
|
|
78
|
+
@by_qualified_name.values
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Check if index is empty
|
|
83
|
+
# @return [Boolean] True if no entities indexed
|
|
84
|
+
def empty?
|
|
85
|
+
@by_qualified_name.empty?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Count total entities
|
|
89
|
+
# @return [Integer] Total number of entities
|
|
90
|
+
def count
|
|
91
|
+
@by_qualified_name.size
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Get entities for a specific schema
|
|
95
|
+
# @param schema [String] Schema name
|
|
96
|
+
# @return [Hash<String, Declarations::Entity>] Entities in schema
|
|
97
|
+
def schema_entities(schema)
|
|
98
|
+
@by_schema[schema.safe_downcase] || {}
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Expressir
|
|
4
|
+
module Model
|
|
5
|
+
module Indexes
|
|
6
|
+
# Index for cross-schema reference tracking
|
|
7
|
+
# Maintains USE FROM, REFERENCE FROM relationships and dependency graphs
|
|
8
|
+
class ReferenceIndex
|
|
9
|
+
attr_reader :use_from, :reference_from, :dependencies
|
|
10
|
+
|
|
11
|
+
# Initialize a new reference index
|
|
12
|
+
# @param schemas [Array<Declarations::Schema>] Schemas to index
|
|
13
|
+
def initialize(schemas = [])
|
|
14
|
+
@use_from = {}
|
|
15
|
+
@reference_from = {}
|
|
16
|
+
@dependencies = {}
|
|
17
|
+
build(schemas) unless schemas.empty?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Build indexes from schemas
|
|
21
|
+
# @param schemas [Array<Declarations::Schema>] Schemas to index
|
|
22
|
+
# @return [void]
|
|
23
|
+
def build(schemas)
|
|
24
|
+
@use_from = {}
|
|
25
|
+
@reference_from = {}
|
|
26
|
+
@dependencies = {}
|
|
27
|
+
|
|
28
|
+
schemas.each do |schema|
|
|
29
|
+
schema_id = schema.id.safe_downcase
|
|
30
|
+
|
|
31
|
+
# Initialize schema entries even if there are no interfaces
|
|
32
|
+
@use_from[schema_id] = []
|
|
33
|
+
@reference_from[schema_id] = []
|
|
34
|
+
|
|
35
|
+
next unless schema.interfaces
|
|
36
|
+
|
|
37
|
+
schema.interfaces.each do |interface|
|
|
38
|
+
referenced_schema = interface.schema.id.safe_downcase
|
|
39
|
+
items = interface.items.map { |item| item.ref.id }
|
|
40
|
+
|
|
41
|
+
if interface.kind == Declarations::Interface::USE
|
|
42
|
+
@use_from[schema_id] << {
|
|
43
|
+
schema: referenced_schema,
|
|
44
|
+
items: items,
|
|
45
|
+
}
|
|
46
|
+
elsif interface.kind == Declarations::Interface::REFERENCE
|
|
47
|
+
@reference_from[schema_id] << {
|
|
48
|
+
schema: referenced_schema,
|
|
49
|
+
items: items,
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
@dependencies[schema_id] ||= Set.new
|
|
54
|
+
@dependencies[schema_id] << referenced_schema
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Get USE FROM references for a schema
|
|
60
|
+
# @param schema [String] Schema name
|
|
61
|
+
# @return [Array<Hash>] List of USE FROM references
|
|
62
|
+
def use_references(schema)
|
|
63
|
+
@use_from[schema.safe_downcase] || []
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Get REFERENCE FROM references for a schema
|
|
67
|
+
# @param schema [String] Schema name
|
|
68
|
+
# @return [Array<Hash>] List of REFERENCE FROM references
|
|
69
|
+
def reference_references(schema)
|
|
70
|
+
@reference_from[schema.safe_downcase] || []
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Get all dependencies for a schema
|
|
74
|
+
# @param schema [String] Schema name
|
|
75
|
+
# @return [Set<String>] Set of dependent schema names
|
|
76
|
+
def schema_dependencies(schema)
|
|
77
|
+
@dependencies[schema.safe_downcase] || Set.new
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Detect circular dependencies in schema references
|
|
81
|
+
# @return [Array<Array<String>>] List of circular dependency cycles
|
|
82
|
+
def detect_circular_dependencies
|
|
83
|
+
cycles = []
|
|
84
|
+
visited = Set.new
|
|
85
|
+
rec_stack = Set.new
|
|
86
|
+
|
|
87
|
+
@dependencies.each_key do |schema_id|
|
|
88
|
+
next if visited.include?(schema_id)
|
|
89
|
+
|
|
90
|
+
path = []
|
|
91
|
+
detect_cycle(schema_id, visited, rec_stack, path, cycles)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
cycles
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Check if index is empty
|
|
98
|
+
# @return [Boolean] True if no references indexed
|
|
99
|
+
def empty?
|
|
100
|
+
@dependencies.empty?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Count total USE FROM references
|
|
104
|
+
# @return [Integer] Total USE FROM references
|
|
105
|
+
def use_from_count
|
|
106
|
+
@use_from.values.sum(&:size)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Count total REFERENCE FROM references
|
|
110
|
+
# @return [Integer] Total REFERENCE FROM references
|
|
111
|
+
def reference_from_count
|
|
112
|
+
@reference_from.values.sum(&:size)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# Helper method for cycle detection using DFS
|
|
118
|
+
# @param node [String] Current schema node
|
|
119
|
+
# @param visited [Set] Set of visited nodes
|
|
120
|
+
# @param rec_stack [Set] Recursion stack for current path
|
|
121
|
+
# @param path [Array] Current path
|
|
122
|
+
# @param cycles [Array] Accumulated cycles
|
|
123
|
+
# @return [Boolean] True if cycle detected
|
|
124
|
+
def detect_cycle(node, visited, rec_stack, path, cycles)
|
|
125
|
+
visited << node
|
|
126
|
+
rec_stack << node
|
|
127
|
+
path << node
|
|
128
|
+
|
|
129
|
+
deps = @dependencies[node] || Set.new
|
|
130
|
+
deps.each do |dep|
|
|
131
|
+
if !visited.include?(dep)
|
|
132
|
+
return true if detect_cycle(dep, visited, rec_stack, path, cycles)
|
|
133
|
+
elsif rec_stack.include?(dep)
|
|
134
|
+
# Found a cycle
|
|
135
|
+
cycle_start = path.index(dep)
|
|
136
|
+
cycles << (path[cycle_start..] + [dep])
|
|
137
|
+
return true
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
path.pop
|
|
142
|
+
rec_stack.delete(node)
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Expressir
|
|
4
|
+
module Model
|
|
5
|
+
module Indexes
|
|
6
|
+
# Index for fast type lookup across schemas
|
|
7
|
+
# Maintains indexes by qualified name, by schema, and by category
|
|
8
|
+
class TypeIndex
|
|
9
|
+
attr_reader :by_schema, :by_qualified_name, :by_category
|
|
10
|
+
|
|
11
|
+
# Initialize a new type index
|
|
12
|
+
# @param schemas [Array<Declarations::Schema>] Schemas to index
|
|
13
|
+
def initialize(schemas = [])
|
|
14
|
+
@by_schema = {}
|
|
15
|
+
@by_qualified_name = {}
|
|
16
|
+
@by_category = {}
|
|
17
|
+
build(schemas) unless schemas.empty?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Build indexes from schemas
|
|
21
|
+
# @param schemas [Array<Declarations::Schema>] Schemas to index
|
|
22
|
+
# @return [void]
|
|
23
|
+
def build(schemas)
|
|
24
|
+
@by_schema = {}
|
|
25
|
+
@by_qualified_name = {}
|
|
26
|
+
@by_category = {}
|
|
27
|
+
|
|
28
|
+
schemas.each do |schema|
|
|
29
|
+
schema_id = schema.id.safe_downcase
|
|
30
|
+
@by_schema[schema_id] = {}
|
|
31
|
+
|
|
32
|
+
next unless schema.types
|
|
33
|
+
|
|
34
|
+
schema.types.each do |type|
|
|
35
|
+
type_id = type.id.safe_downcase
|
|
36
|
+
qualified_name = "#{schema_id}.#{type_id}"
|
|
37
|
+
category = categorize(type)
|
|
38
|
+
|
|
39
|
+
@by_schema[schema_id][type_id] = type
|
|
40
|
+
@by_qualified_name[qualified_name] = type
|
|
41
|
+
@by_category[category] ||= []
|
|
42
|
+
@by_category[category] << type
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Find type by qualified name
|
|
48
|
+
# @param qualified_name [String] Type qualified name (e.g., "schema.type")
|
|
49
|
+
# @return [Declarations::Type, nil] Found type or nil
|
|
50
|
+
def find(qualified_name)
|
|
51
|
+
return nil if qualified_name.nil? || qualified_name.empty?
|
|
52
|
+
|
|
53
|
+
normalized_name = qualified_name.safe_downcase
|
|
54
|
+
|
|
55
|
+
# Try qualified name first
|
|
56
|
+
type = @by_qualified_name[normalized_name]
|
|
57
|
+
return type if type
|
|
58
|
+
|
|
59
|
+
# Try as simple name across all schemas
|
|
60
|
+
schema_name, type_name = normalized_name.split(".", 2)
|
|
61
|
+
if type_name.nil?
|
|
62
|
+
# Search all schemas for simple name
|
|
63
|
+
@by_schema.each_value do |types|
|
|
64
|
+
type = types[schema_name]
|
|
65
|
+
return type if type
|
|
66
|
+
end
|
|
67
|
+
else
|
|
68
|
+
# Look in specific schema
|
|
69
|
+
schema_types = @by_schema[schema_name]
|
|
70
|
+
return schema_types[type_name] if schema_types
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# List all types
|
|
77
|
+
# @param schema [String, nil] Filter by schema name
|
|
78
|
+
# @param category [String, nil] Filter by category
|
|
79
|
+
# @return [Array<Declarations::Type>] List of types
|
|
80
|
+
def list(schema: nil, category: nil)
|
|
81
|
+
types = if schema
|
|
82
|
+
@by_schema[schema.safe_downcase]&.values || []
|
|
83
|
+
else
|
|
84
|
+
@by_qualified_name.values
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
types = filter_by_category(types, category) if category
|
|
88
|
+
|
|
89
|
+
types
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Check if index is empty
|
|
93
|
+
# @return [Boolean] True if no types indexed
|
|
94
|
+
def empty?
|
|
95
|
+
@by_qualified_name.empty?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Count total types
|
|
99
|
+
# @return [Integer] Total number of types
|
|
100
|
+
def count
|
|
101
|
+
@by_qualified_name.size
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Get types for a specific schema
|
|
105
|
+
# @param schema [String] Schema name
|
|
106
|
+
# @return [Hash<String, Declarations::Type>] Types in schema
|
|
107
|
+
def schema_types(schema)
|
|
108
|
+
@by_schema[schema.safe_downcase] || {}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Get types for a specific category
|
|
112
|
+
# @param category [String] Category name
|
|
113
|
+
# @return [Array<Declarations::Type>] Types in category
|
|
114
|
+
def category_types(category)
|
|
115
|
+
@by_category[category] || []
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Categorize type by its underlying type
|
|
119
|
+
# @param type [Declarations::Type] Type to categorize
|
|
120
|
+
# @return [String] Type category
|
|
121
|
+
def categorize(type)
|
|
122
|
+
return "defined" unless type.underlying_type
|
|
123
|
+
|
|
124
|
+
underlying = type.underlying_type
|
|
125
|
+
|
|
126
|
+
# Use explicit is_a? checks for test double compatibility
|
|
127
|
+
return "select" if underlying.is_a?(DataTypes::Select)
|
|
128
|
+
return "enumeration" if underlying.is_a?(DataTypes::Enumeration)
|
|
129
|
+
return "aggregate" if underlying.is_a?(DataTypes::Array) ||
|
|
130
|
+
underlying.is_a?(DataTypes::Bag) ||
|
|
131
|
+
underlying.is_a?(DataTypes::List) ||
|
|
132
|
+
underlying.is_a?(DataTypes::Set)
|
|
133
|
+
|
|
134
|
+
"defined"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# Filter types by category
|
|
140
|
+
# @param types [Array<Declarations::Type>] List of types
|
|
141
|
+
# @param category [String] Category to filter by
|
|
142
|
+
# @return [Array<Declarations::Type>] Filtered types
|
|
143
|
+
def filter_by_category(types, category)
|
|
144
|
+
types.select { |type| categorize(type) == category }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|