lutaml-model 0.8.10 → 0.8.11

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1dea5a70c106b45dafcf2950dda9a8a1c2d8f2d37a4d32ec9a610e461b97a91
4
- data.tar.gz: ca6332ea17ae83c4473a5143d454f155006ad369759dab98c48a1ba825aa6d74
3
+ metadata.gz: 992b8e51bee9c168f1dbb4cac0fcfd75f593f1c40cc29abce7e2163088e6fc48
4
+ data.tar.gz: 8e3524b5e4336e8cb289c3f31b513727bb2bbb23d3429fcd770425b6f24de489
5
5
  SHA512:
6
- metadata.gz: 01c6623cc80b904875abe68601be5f4b76f441a0b6d0a599325db9454152072c4548985b29e12656b79cffeb28a965ddbf2d3191c6dc617503d914324cee3d35
7
- data.tar.gz: 38c58ea41a62c035f56ba5a6ae758ed45e861417d7b41dba4003418600f36a88b6ab1a6ab753bd3ff54c3c45a2ed25ac13a68c774c9f488e9785d3d5c44894c6
6
+ metadata.gz: 61c349cf6a1476a0c8668ea19b45e2989275a00d97ab718613eb80cfdc86a2e71ba0cb344c69a6877c5447a57d715f080470c3b28dce0c046652502182b29408
7
+ data.tar.gz: b85e8858876efa7b98acec6df1ff36e2ebbbf772c8b8cf035a459374159d2c5d704c13cbcecc99a69561438884d05bc4f52d13e12d6cd1b3f63611761344bd04
@@ -29,6 +29,7 @@
29
29
  "relaton/loc_mods",
30
30
  "relaton/w3c_api",
31
31
  "ukiryu/ukiryu",
32
+ "unitsml/unitsdb-ruby",
32
33
  "unitsml/unitsml-ruby"
33
34
  ]
34
35
  }
data/.rubocop_todo.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config`
3
- # on 2026-05-18 04:34:59 UTC using RuboCop version 1.86.0.
3
+ # on 2026-05-20 09:01:31 UTC using RuboCop version 1.86.0.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
@@ -11,13 +11,52 @@ Gemspec/RequiredRubyVersion:
11
11
  Exclude:
12
12
  - 'lutaml-model.gemspec'
13
13
 
14
- # Offense count: 3012
14
+ # Offense count: 2
15
+ # This cop supports safe autocorrection (--autocorrect).
16
+ # Configuration parameters: EnforcedStyle, IndentationWidth.
17
+ # SupportedStyles: with_first_argument, with_fixed_indentation
18
+ Layout/ArgumentAlignment:
19
+ Exclude:
20
+ - 'lib/lutaml/turtle/transform.rb'
21
+ - 'spec/lutaml/model/store_spec.rb'
22
+
23
+ # Offense count: 3
24
+ # This cop supports safe autocorrection (--autocorrect).
25
+ # Configuration parameters: EnforcedStyleAlignWith.
26
+ # SupportedStylesAlignWith: either, start_of_block, start_of_line
27
+ Layout/BlockAlignment:
28
+ Exclude:
29
+ - 'spec/lutaml/model/store_spec.rb'
30
+
31
+ # Offense count: 3
32
+ # This cop supports safe autocorrection (--autocorrect).
33
+ Layout/BlockEndNewline:
34
+ Exclude:
35
+ - 'spec/lutaml/model/store_spec.rb'
36
+
37
+ # Offense count: 6
38
+ # This cop supports safe autocorrection (--autocorrect).
39
+ # Configuration parameters: Width, EnforcedStyleAlignWith, AllowedPatterns.
40
+ # SupportedStylesAlignWith: start_of_line, relative_to_receiver
41
+ Layout/IndentationWidth:
42
+ Exclude:
43
+ - 'spec/lutaml/model/store_spec.rb'
44
+
45
+ # Offense count: 3024
15
46
  # This cop supports safe autocorrection (--autocorrect).
16
47
  # Configuration parameters: Max, AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings.
17
48
  # URISchemes: http, https
18
49
  Layout/LineLength:
19
50
  Enabled: false
20
51
 
52
+ # Offense count: 2
53
+ # This cop supports safe autocorrection (--autocorrect).
54
+ # Configuration parameters: AllowInHeredoc.
55
+ Layout/TrailingWhitespace:
56
+ Exclude:
57
+ - 'lib/lutaml/turtle/transform.rb'
58
+ - 'spec/lutaml/model/store_spec.rb'
59
+
21
60
  # Offense count: 21
22
61
  # Configuration parameters: AllowedMethods.
23
62
  # AllowedMethods: enums
@@ -130,12 +169,12 @@ Metrics/BlockLength:
130
169
  Metrics/BlockNesting:
131
170
  Max: 6
132
171
 
133
- # Offense count: 310
172
+ # Offense count: 308
134
173
  # Configuration parameters: AllowedMethods, AllowedPatterns, Max.
135
174
  Metrics/CyclomaticComplexity:
136
175
  Enabled: false
137
176
 
138
- # Offense count: 554
177
+ # Offense count: 553
139
178
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
140
179
  Metrics/MethodLength:
141
180
  Max: 514
@@ -249,7 +288,7 @@ RSpec/DescribeMethod:
249
288
  - 'spec/lutaml/xml/schema/xsd/schema_helper_methods_spec.rb'
250
289
  - 'spec/lutaml/xml/serializable_namespace_spec.rb'
251
290
 
252
- # Offense count: 1257
291
+ # Offense count: 1285
253
292
  # Configuration parameters: CountAsOne.
254
293
  RSpec/ExampleLength:
255
294
  Max: 68
@@ -324,7 +363,7 @@ RSpec/MultipleDescribes:
324
363
  - 'spec/lutaml/xml/namespace_resolution_strategy_spec.rb'
325
364
  - 'spec/lutaml/xml/xml_space_type_spec.rb'
326
365
 
327
- # Offense count: 1490
366
+ # Offense count: 1507
328
367
  RSpec/MultipleExpectations:
329
368
  Max: 21
330
369
 
@@ -398,6 +437,17 @@ Security/MarshalLoad:
398
437
  Exclude:
399
438
  - 'scripts-xmi-profile/profile_xmi.rb'
400
439
 
440
+ # Offense count: 5
441
+ # This cop supports safe autocorrection (--autocorrect).
442
+ # Configuration parameters: EnforcedStyle, ProceduralMethods, FunctionalMethods, AllowedMethods, AllowedPatterns, AllowBracesOnProceduralOneLiners, BracesRequiredMethods.
443
+ # SupportedStyles: line_count_based, semantic, braces_for_chaining, always_braces
444
+ # ProceduralMethods: benchmark, bm, bmbm, create, each_with_object, measure, new, realtime, tap, with_object
445
+ # FunctionalMethods: let, let!, subject, watch
446
+ # AllowedMethods: lambda, proc, it
447
+ Style/BlockDelimiters:
448
+ Exclude:
449
+ - 'spec/lutaml/model/store_spec.rb'
450
+
401
451
  # Offense count: 2
402
452
  # This cop supports unsafe autocorrection (--autocorrect-all).
403
453
  # Configuration parameters: AllowedMethods, AllowedPatterns.
data/README.adoc CHANGED
@@ -5507,6 +5507,49 @@ vocab.to_turtle
5507
5507
  vocab.to_jsonld
5508
5508
  ----
5509
5509
 
5510
+ ==== Multiple RDF types
5511
+
5512
+ `type` accepts a single compact IRI or an array:
5513
+
5514
+ [source,ruby]
5515
+ ----
5516
+ type "skos:Concept"
5517
+ # or multiple types:
5518
+ type ["skos:Concept", "dcterms:Agent"]
5519
+ ----
5520
+
5521
+ In Turtle, each type produces a separate `a` triple. In JSON-LD, a single type
5522
+ produces `"@type": "skos:Concept"` while multiple types produce an array.
5523
+
5524
+ ==== URI reference predicates
5525
+
5526
+ Predicates declared with `uri_reference: true` serialize values as URI objects
5527
+ rather than string literals. In Turtle, values are emitted without quotes. In
5528
+ JSON-LD, values are wrapped in `{"@id": ...}` and the context term includes
5529
+ `"@type": "@id"`.
5530
+
5531
+ [source,ruby]
5532
+ ----
5533
+ predicate :related, namespace: SkosNamespace, to: :related, uri_reference: true
5534
+ ----
5535
+
5536
+ Round-trip fidelity is preserved: compact IRI forms (e.g. `"skos:other"`) are
5537
+ maintained through serialization and deserialization.
5538
+
5539
+ ==== Linked member predicates
5540
+
5541
+ `members` accepts `predicate_name:` and `namespace:` to generate linking triples
5542
+ from the container to each member:
5543
+
5544
+ [source,ruby]
5545
+ ----
5546
+ members :children, predicate_name: :member, namespace: SkosNamespace
5547
+ ----
5548
+
5549
+ In Turtle, this produces `skos:member <child-uri>` triples on the container
5550
+ subject. In JSON-LD, the context includes a `member` term with `"@type": "@id"`
5551
+ and the container resource contains `{"@id": "..."}` references.
5552
+
5510
5553
  See link:docs/_guides/rdf-serialization.adoc[Unified RDF Serialization] for the
5511
5554
  complete guide including language-tagged values, graph serialization, and
5512
5555
  architecture details.
@@ -160,7 +160,9 @@ end
160
160
  ----
161
161
 
162
162
  See link:../_guides/rdf-serialization.adoc[Unified RDF Serialization] for the
163
- complete guide including graph-level serialization with `members`.
163
+ complete guide including graph-level serialization with `members`, URI reference
164
+ predicates (`uri_reference: true`), multiple RDF types, and linking predicates
165
+ on member collections.
164
166
 
165
167
  == Multi-format models
166
168
 
@@ -94,15 +94,22 @@ subject { |m| "http://example.org/concept/#{m.code}" }
94
94
 
95
95
  ==== type
96
96
 
97
- Sets the RDF type (`rdf:type`). Compact IRIs are resolved via declared
98
- namespaces:
97
+ Sets the RDF type (`rdf:type`). Accepts a single compact IRI or an array of
98
+ compact IRIs. Compact IRIs are resolved via declared namespaces:
99
99
 
100
100
  [source,ruby]
101
101
  ----
102
102
  type "skos:Concept"
103
103
  # resolves to <http://www.w3.org/2004/02/skos/core#Concept>
104
+
105
+ # multiple types:
106
+ type ["skos:Concept", "dcterms:Agent"]
104
107
  ----
105
108
 
109
+ In Turtle, each type produces a separate `a` triple. In JSON-LD, a single type
110
+ produces `"@type": "skos:Concept"` while multiple types produce an array
111
+ `"@type": ["skos:Concept", "dcterms:Agent"]`.
112
+
106
113
  ==== predicate
107
114
 
108
115
  Each `predicate` creates a mapping between an RDF predicate and a model
@@ -119,13 +126,38 @@ Parameters:
119
126
  * `namespace:` — the `Lutaml::Rdf::Namespace` subclass (required)
120
127
  * `to:` — the model attribute to read (required)
121
128
  * `lang_tagged:` (default: `false`) — if true, values are serialized with
122
- language tags (see <<language-tagged-values>>)
129
+ language tags (see <<language-tagged-values>>). Mutually exclusive with
130
+ `uri_reference`.
131
+ * `uri_reference:` (default: `false`) — if true, values are serialized as URI
132
+ references rather than string literals (see <<uri-reference-predicates>>).
133
+ Mutually exclusive with `lang_tagged`.
123
134
 
124
135
  ==== members
125
136
 
126
137
  Declares that a container model contains member resources that should be
127
138
  serialized as separate subjects in the output graph (see <<graph-serialization>>).
128
139
 
140
+ Optional linking predicate parameters generate relationship triples from the
141
+ container to each member:
142
+
143
+ [source,ruby]
144
+ ----
145
+ members :concepts, predicate_name: :member, namespace: SkosNamespace
146
+ ----
147
+
148
+ When `predicate_name` and `namespace` are provided:
149
+
150
+ * **Turtle**: produces `skos:member <child-uri>` triples on the container subject
151
+ * **JSON-LD**: adds a `member` term to `@context` with `"@type": "@id"` and
152
+ `{"@id": "..."}` references in the container resource
153
+
154
+ Parameters:
155
+
156
+ * `attr_name` — the model attribute holding the member collection (required)
157
+ * `predicate_name:` — the local name for the linking predicate (optional)
158
+ * `namespace:` — the `Lutaml::Rdf::Namespace` subclass for the linking predicate
159
+ (required when `predicate_name` is given)
160
+
129
161
  == Serialization
130
162
 
131
163
  === Turtle
@@ -207,6 +239,48 @@ class LocalizedLiteral < Lutaml::Model::Serializable
207
239
  end
208
240
  ----
209
241
 
242
+ == [[uri-reference-predicates]]URI Reference Predicates
243
+
244
+ Predicates declared with `uri_reference: true` serialize attribute values as
245
+ URI references rather than string literals:
246
+
247
+ [source,ruby]
248
+ ----
249
+ predicate :related, namespace: SkosNamespace, to: :related, uri_reference: true
250
+ ----
251
+
252
+ **Turtle:**
253
+
254
+ Values are emitted as URI objects without quotes. Compact IRIs (e.g.
255
+ `"skos:other"`) are resolved to full URIs using the declared namespaces. Full
256
+ URIs (e.g. `"http://example.org/foo"`) are used as-is.
257
+
258
+ [source,turtle]
259
+ ----
260
+ <http://example.org/concept/1> skos:related skos:other .
261
+ ----
262
+
263
+ **JSON-LD:**
264
+
265
+ Values are wrapped in `{"@id": ...}` objects. The auto-generated `@context`
266
+ includes a term definition with `"@type": "@id"`:
267
+
268
+ [source,json]
269
+ ----
270
+ {
271
+ "@context": {
272
+ "related": { "@id": "http://www.w3.org/2004/02/skos/core#related", "@type": "@id" }
273
+ },
274
+ "related": [ { "@id": "skos:other" } ]
275
+ }
276
+ ----
277
+
278
+ Round-trip fidelity: compact IRI forms are preserved through serialization and
279
+ deserialization. Values round-trip as `Concept.from_turtle(concept.to_turtle)`
280
+ preserving the original `"skos:other"` compact form.
281
+
282
+ The `uri_reference` option is mutually exclusive with `lang_tagged`.
283
+
210
284
  == [[graph-serialization]]Graph Serialization
211
285
 
212
286
  When a container model holds a collection of member resources, use `members` to
@@ -227,11 +301,16 @@ class Vocabulary < Lutaml::Model::Serializable
227
301
  type "skos:ConceptScheme"
228
302
  predicate :prefLabel, namespace: SkosNamespace, to: :id
229
303
 
230
- members :concepts
304
+ members :concepts,
305
+ predicate_name: :member,
306
+ namespace: SkosNamespace
231
307
  end
232
308
  end
233
309
  ----
234
310
 
311
+ The `predicate_name` and `namespace` parameters on `members` generate linking
312
+ triples from the container to each member.
313
+
235
314
  === Turtle Output with Members
236
315
 
237
316
  [source,ruby]
@@ -247,7 +326,9 @@ Produces a single Turtle document with the container and all member triples:
247
326
  @prefix skos: <http://www.w3.org/2004/02/skos/core#> .
248
327
 
249
328
  <http://example.org/vocab/iso1087> a skos:ConceptScheme;
250
- skos:prefLabel "iso1087" .
329
+ skos:prefLabel "iso1087";
330
+ skos:member <http://example.org/concept/2119>;
331
+ skos:member <http://example.org/concept/2120> .
251
332
 
252
333
  <http://example.org/concept/2119> a skos:Concept;
253
334
  skos:notation "2119";
@@ -272,14 +353,19 @@ Produces a JSON-LD document with `@graph` containing all resources:
272
353
  {
273
354
  "@context": {
274
355
  "skos": "http://www.w3.org/2004/02/skos/core#",
275
- "prefLabel": "skos:prefLabel",
276
- "notation": "skos:notation"
356
+ "prefLabel": { "@id": "skos:prefLabel" },
357
+ "notation": { "@id": "skos:notation" },
358
+ "member": { "@id": "http://www.w3.org/2004/02/skos/core#member", "@type": "@id" }
277
359
  },
278
360
  "@graph": [
279
361
  {
280
362
  "@id": "http://example.org/vocab/iso1087",
281
363
  "@type": "skos:ConceptScheme",
282
- "prefLabel": "iso1087"
364
+ "prefLabel": "iso1087",
365
+ "member": [
366
+ { "@id": "http://example.org/concept/2119" },
367
+ { "@id": "http://example.org/concept/2120" }
368
+ ]
283
369
  },
284
370
  {
285
371
  "@id": "http://example.org/concept/2119",
@@ -73,9 +73,18 @@ Member models (serialized via a container's `members` declaration) can omit the
73
73
 
74
74
  === type
75
75
 
76
- Sets the RDF type (`rdf:type`) triple. Compact IRIs (e.g., `"skos:Concept"`)
77
- are resolved to full URIs via the declared namespaces. Full URIs (e.g.,
78
- `"http://example.org/MyType"`) are used as-is.
76
+ Sets the RDF type (`rdf:type`) triple. Accepts a single compact IRI or an array:
77
+
78
+ [source,ruby]
79
+ ----
80
+ type "skos:Concept"
81
+ # or multiple types:
82
+ type ["skos:Concept", "dcterms:Agent"]
83
+ ----
84
+
85
+ Compact IRIs are resolved to full URIs via the declared namespaces. Full URIs
86
+ (e.g., `"http://example.org/MyType"`) are used as-is. Each type produces a
87
+ separate `a` triple in the output.
79
88
 
80
89
  === predicate
81
90
 
@@ -85,7 +94,11 @@ Each `predicate` creates a `Lutaml::Rdf::MappingRule` value object:
85
94
  * `namespace:` — the `Lutaml::Rdf::Namespace` subclass (required, validated)
86
95
  * `to:` — the model attribute to read/write (required)
87
96
  * `lang_tagged:` (default: `false`) — if true, appends `@lang` suffix from the
88
- value's `language_code` or `language` method
97
+ value's `language_code` or `language` method. Mutually exclusive with
98
+ `uri_reference`.
99
+ * `uri_reference:` (default: `false`) — if true, serializes values as URI
100
+ objects rather than string literals. Compact IRIs are resolved via the
101
+ declared namespaces. Mutually exclusive with `lang_tagged`.
89
102
 
90
103
  Validation errors:
91
104
 
@@ -35,7 +35,7 @@ module Lutaml
35
35
  value = hash[rule.predicate_name]
36
36
  next if value.nil?
37
37
 
38
- attrs[rule.to] = if rule.lang_tagged && value.is_a?(Hash)
38
+ attrs[rule.to] = if rule.kind == :lang_tagged && value.is_a?(Hash)
39
39
  flatten_language_map(value)
40
40
  else
41
41
  value
@@ -61,9 +61,8 @@ module Lutaml
61
61
  end
62
62
 
63
63
  mapping.rdf_members.each do |member_rule|
64
- collection = Array(instance.public_send(member_rule.attr_name))
65
- collection.each do |member|
66
- member_mapping = member.class.mappings[:jsonld]
64
+ each_member(instance, member_rule) do |member|
65
+ member_mapping = member_mapping_for(member, :jsonld)
67
66
  next unless member_mapping
68
67
 
69
68
  resource = build_resource_data(member_mapping, member)
@@ -78,13 +77,12 @@ module Lutaml
78
77
  context_hash = build_context_from_mapping(mapping).to_hash
79
78
 
80
79
  mapping.rdf_members.each do |member_rule|
81
- collection = Array(instance.public_send(member_rule.attr_name))
82
- next if collection.empty?
83
-
84
- member_mapping = collection.first.class.mappings[:jsonld]
85
- next unless member_mapping
80
+ each_member(instance, member_rule) do |member|
81
+ member_mapping = member_mapping_for(member, :jsonld)
82
+ next unless member_mapping
86
83
 
87
- context_hash.merge!(build_context_from_mapping(member_mapping).to_hash)
84
+ context_hash.merge!(build_context_from_mapping(member_mapping).to_hash)
85
+ end
88
86
  end
89
87
 
90
88
  context_hash
@@ -94,17 +92,30 @@ module Lutaml
94
92
  context = Context.new
95
93
  mapping.namespace_set.each { |ns| context.prefix(ns) }
96
94
  mapping.rdf_predicates.each do |pred|
97
- if pred.lang_tagged
98
- context.term(pred.predicate_name,
99
- id: pred.uri,
100
- container: :language)
101
- else
102
- context.term(pred.predicate_name, id: pred.uri)
103
- end
95
+ options = { id: pred.uri }
96
+ term_options = context_term_options(pred)
97
+ context.term(pred.predicate_name, **options, **term_options)
104
98
  end
99
+
100
+ mapping.rdf_members.each do |member_rule|
101
+ next unless member_rule.linked?
102
+
103
+ context.term(member_rule.predicate_name.to_s,
104
+ id: member_rule.linked_predicate_uri,
105
+ type: "@id")
106
+ end
107
+
105
108
  context
106
109
  end
107
110
 
111
+ def context_term_options(rule)
112
+ case rule.kind
113
+ when :uri_reference then { type: "@id" }
114
+ when :lang_tagged then { container: :language }
115
+ else {}
116
+ end
117
+ end
118
+
108
119
  def build_resource_object(mapping, instance)
109
120
  context = build_context_from_mapping(mapping).to_hash
110
121
  data = build_resource_data(mapping, instance)
@@ -114,8 +125,12 @@ module Lutaml
114
125
  def build_resource_data(mapping, instance)
115
126
  result = {}
116
127
 
117
- if mapping.rdf_type
118
- result["@type"] = resolve_type_compact(mapping)
128
+ if mapping.rdf_type.any?
129
+ result["@type"] = if mapping.rdf_type.length == 1
130
+ mapping.rdf_type.first
131
+ else
132
+ mapping.rdf_type
133
+ end
119
134
  end
120
135
 
121
136
  if mapping.rdf_subject
@@ -127,16 +142,47 @@ module Lutaml
127
142
  next if value.nil?
128
143
  next if value.is_a?(String) && value.empty?
129
144
 
130
- result[rule.predicate_name] = if rule.lang_tagged
131
- build_language_map(value)
132
- else
133
- serialize_rdf_value(value)
134
- end
145
+ result[rule.predicate_name] = serialize_value(value, rule)
146
+ end
147
+
148
+ mapping.rdf_members.each do |member_rule|
149
+ next unless member_rule.linked?
150
+
151
+ member_refs = collect_member_references(instance, member_rule)
152
+ next if member_refs.empty?
153
+
154
+ result[member_rule.predicate_name.to_s] = member_refs
135
155
  end
136
156
 
137
157
  result
138
158
  end
139
159
 
160
+ def collect_member_references(instance, member_rule)
161
+ refs = []
162
+ each_member(instance, member_rule) do |member|
163
+ member_mapping = member_mapping_for(member, :jsonld)
164
+ next unless member_mapping
165
+
166
+ refs << { "@id" => resolve_subject_uri(member_mapping, member) }
167
+ end
168
+ refs
169
+ end
170
+
171
+ def serialize_value(value, rule)
172
+ case rule.kind
173
+ when :uri_reference then serialize_uri_reference(value)
174
+ when :lang_tagged then build_language_map(value)
175
+ else serialize_rdf_value(value)
176
+ end
177
+ end
178
+
179
+ def serialize_uri_reference(value)
180
+ case value
181
+ when Array then value.map { |v| { "@id" => v.to_s } }
182
+ else { "@id" => value.to_s }
183
+ end
184
+ end
185
+
140
186
  def build_language_map(values)
141
187
  case values
142
188
  when Array
@@ -5,9 +5,15 @@ require "weakref"
5
5
  module Lutaml
6
6
  module Model
7
7
  class Store
8
- # Compact dead WeakRef shells after this many entries per class bucket.
8
+ # Compact dead WeakRef shells once a class bucket grows past this size.
9
9
  COMPACTION_THRESHOLD = 1000
10
10
 
11
+ # Once the threshold is exceeded, only compact every Nth subsequent
12
+ # register call. Amortises the O(N) reject! over N inserts so
13
+ # register stays O(1) per call rather than O(N) per call (O(N^2)
14
+ # cumulatively for the class).
15
+ COMPACTION_INTERVAL = 1000
16
+
11
17
  class << self
12
18
  def instance
13
19
  @instance ||= new
@@ -39,14 +45,17 @@ module Lutaml
39
45
  # Nested index: { model_key => { reference_key => { value => WeakRef(object) } } }
40
46
  # Grouped by model_key so register only iterates this class's own indices.
41
47
  @index = {}
48
+ @inserts_since_compaction = ::Hash.new(0)
49
+ @compaction_count = 0
42
50
  end
43
51
 
44
52
  def register(object)
45
53
  model_key = object.class.to_s
46
54
  refs = @store[model_key]
47
55
  refs << WeakRef.new(object)
56
+ @inserts_since_compaction[model_key] += 1
48
57
 
49
- compact_if_needed(refs)
58
+ compact_if_needed(refs, model_key)
50
59
 
51
60
  update_existing_indices(object, model_key)
52
61
  end
@@ -63,12 +72,16 @@ module Lutaml
63
72
  entry = model_indices[reference_key][reference_value]
64
73
  return nil unless entry
65
74
 
66
- dereference(entry)
75
+ obj = dereference(entry)
76
+ model_indices[reference_key].delete(reference_value) unless obj
77
+ obj
67
78
  end
68
79
 
69
80
  def clear
70
81
  @store = ::Hash.new { |hash, key| hash[key] = [] }
71
82
  @index = {}
83
+ @inserts_since_compaction = ::Hash.new(0)
84
+ @compaction_count = 0
72
85
  end
73
86
 
74
87
  def store
@@ -81,6 +94,22 @@ module Lutaml
81
94
  end
82
95
  end
83
96
 
97
+ def refs_for(model_key)
98
+ @store[model_key]
99
+ end
100
+
101
+ def inserts_since_compaction
102
+ @inserts_since_compaction
103
+ end
104
+
105
+ def compaction_count
106
+ @compaction_count
107
+ end
108
+
109
+ def index_entry_count(model_key)
110
+ @index[model_key]&.sum { |_reference_key, entries| entries.size } || 0
111
+ end
112
+
84
113
  private
85
114
 
86
115
  def ensure_model_index(model_key)
@@ -120,14 +149,32 @@ module Lutaml
120
149
  nil
121
150
  end
122
151
 
123
- def compact_if_needed(refs)
152
+ def compact_if_needed(refs, model_key)
124
153
  return unless refs.size > COMPACTION_THRESHOLD
154
+ return unless @inserts_since_compaction[model_key] >= COMPACTION_INTERVAL
125
155
 
156
+ @inserts_since_compaction[model_key] = 0
157
+ @compaction_count += 1
126
158
  refs.reject! do |ref|
127
159
  !ref.weakref_alive?
128
160
  rescue WeakRef::RefError
129
161
  true
130
162
  end
163
+ prune_index(model_key)
164
+ end
165
+
166
+ def prune_index(model_key)
167
+ model_indices = @index[model_key]
168
+ return unless model_indices
169
+
170
+ model_indices.delete_if do |_reference_key, entries|
171
+ entries.delete_if do |_value, ref|
172
+ !ref.weakref_alive?
173
+ rescue WeakRef::RefError
174
+ true
175
+ end
176
+ entries.empty?
177
+ end
131
178
  end
132
179
  end
133
180
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.10"
5
+ VERSION = "0.8.11"
6
6
  end
7
7
  end