pg_graph 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/type/type.rb ADDED
@@ -0,0 +1,617 @@
1
+
2
+ module PgGraph::Type
3
+ class Error < StandardError; end
4
+
5
+ class Node < HashTree::Set
6
+ include Constrain
7
+
8
+ alias_method :uid, :path
9
+ def guid() root.name + "." + uid end
10
+
11
+ alias_method :name, :key
12
+
13
+ # The Ruby model identifier of the object. It corresponds to #name
14
+ def identifier() PgGraph.inflector.camelize(name) end
15
+
16
+ # The full Ruby model identifier of the object within a database. It
17
+ # corresponds to #uid
18
+ def schema_identifier() identifier end
19
+
20
+ # :call-seq: initialize(database, name, attach: true)
21
+ def initialize(parent, name, **opts)
22
+ constrain parent, Node, NilClass
23
+ constrain name, String, NilClass
24
+ super
25
+ end
26
+
27
+ def inspect(payload = inspect_inner)
28
+ "#<#{self.class}:#{name.inspect}#{payload ? " #{payload}" : ""}>"
29
+ end
30
+
31
+ def inspect_inner() nil end
32
+
33
+ protected
34
+ # Nodes with nil keys are not attached. This is used in PgCatalogSchema to
35
+ # avoid being included in the list of schemas
36
+ def do_attach(key, child) super if child end
37
+
38
+ # Forward list of methods to object. The arguments should be strings or symbols
39
+ def self.forward_method(object, *methods)
40
+ for method in Array(methods).flatten
41
+ class_eval("def #{method}(*args) #{object}.#{method}(*args) end")
42
+ end
43
+ end
44
+ end
45
+
46
+ # Included by tables, records, and fields
47
+ module TableObject
48
+ # Circular definitions. Classes that include TableObject should redefine at least
49
+ # one of the methods to break the loop
50
+ def table() record_type.table end
51
+ def table_type() table.type end
52
+ def record_type() table_type.record_type end
53
+ end
54
+
55
+ # Database is the top-level object and #read is the starting point for
56
+ # loading the type system in PgGraph::Type.new
57
+ class Database < Node
58
+ def guid() name end
59
+
60
+ alias_method :schemas, :values
61
+ attr_reader :catalog
62
+
63
+ # List of all tables in the database
64
+ def tables() schemas.map(&:tables).flatten end
65
+
66
+ # The reflector object. The reflector object takes a name of a link field
67
+ # and produce the field name in this table and the corrsponding field name
68
+ # in the other table
69
+ attr_reader :reflector
70
+
71
+ def initialize(name, reflector = PgGraph::Reflector.new)
72
+ constrain name, String
73
+ constrain reflector, PgGraph::Reflector
74
+ super(nil, name)
75
+ @catalog = PgCatalogSchema.new(self)
76
+ @reflector = reflector
77
+ end
78
+
79
+ def dot_lookup(key)
80
+ super || catalog[key]
81
+ end
82
+
83
+ # TODO
84
+ # def to_yaml() end
85
+ # def self.load_yaml() end
86
+
87
+ # Make Database pretend it is a PgGraph::Type object
88
+ def is_a?(klass) klass == PgGraph::Type || super end
89
+
90
+ def read(arg, reflector = nil, ignore: [])
91
+ constrain arg, PgMeta, PgConn
92
+ @reflector = reflector || @reflector
93
+ case arg
94
+ # #read_meta is a member of Database but defined in read.rb
95
+ when PgMeta; read_meta(arg, ignore: ignore)
96
+ when PgConn; read_meta(PgMeta.new(arg), ignore: ignore)
97
+ end
98
+ end
99
+ end
100
+
101
+ class Schema < Node
102
+ alias_method :database, :parent
103
+ alias_method :objects, :values
104
+
105
+ def tables() objects.select { |child| child.is_a?(Table) } end
106
+ def types() objects.select { |child| child.is_a?(Type) } end
107
+ def record_types() objects.select { |child| child.is_a?(RecordType) } end
108
+
109
+ # :call-seq: initialize(database, name, attach: true)
110
+ #
111
+ # +attach:false+ is used when initializing the pg_catalog schema object
112
+ def initialize(database, name, **opts)
113
+ constrain database, Database
114
+ super(database, name, **opts)
115
+ end
116
+ end
117
+
118
+ class PgCatalogSchema < Schema
119
+ def schema_identifier() nil end
120
+ def initialize(database) super(database, "pg_catalog", attach: false) end
121
+ end
122
+
123
+ # Schema objects are owned by the schema. This includes tables and
124
+ # user-defined types but not records that are owned by their tables
125
+ class SchemaObject < Node
126
+ alias_method :schema, :parent
127
+
128
+ def initialize(schema, name)
129
+ constrain schema, Schema
130
+ super
131
+ end
132
+
133
+ def schema_identifier
134
+ [schema.schema_identifier, identifier].compact.join("::")
135
+ end
136
+ end
137
+
138
+ # A table. Note that a table doesn't have child objects. Instead it has a type
139
+ # property that has the record type as child
140
+ class Table < SchemaObject
141
+ include TableObject
142
+
143
+ # Qualified name of table (ie. schema.table). FIXME: We already have Node#path???
144
+ attr_reader :path
145
+
146
+ # Table type. Initially nil. Initialized by RecordType.new
147
+ attr_reader :type
148
+ alias_method :table_type, :type # Required TableObject method
149
+
150
+ forward_method :type, :keys, :fields, :key?, :[], :columns, :value_columns, :column?
151
+
152
+ # Parent table if table is a derived table and otherwise nil. Initialized by #read
153
+ attr_reader :supertable
154
+
155
+ # True if table is a parent table. Initialized by #read
156
+ def supertable?() @has_subtables || false end
157
+
158
+ # True if table is a derived table
159
+ def subtable?() !@supertable.nil? end
160
+
161
+ def mm_table?() @mm_table end
162
+ def nm_table?() @nm_table end
163
+
164
+ # Array of tables in the transitive closure of table dependencies. It is
165
+ # initialized by Database#read_meta
166
+ attr_reader :depending_tables
167
+
168
+ # This is a hack since graph_db doesn't model views. It is needed because
169
+ # PgGraph::Data needs to know which materialized views to refresh after
170
+ # data has been loaded
171
+ attr_reader :depending_materialized_views
172
+
173
+ def initialize(
174
+ schema, name,
175
+ mm_table: false, nm_table: false, depending_materialized_views: [])
176
+ PgGraph.inflector.plural?(name) or raise Error, "Table names should be plural: #{name.inspect}"
177
+ super(schema, name)
178
+ @path = "#{schema.name}.#{name}"
179
+ @mm_table = mm_table || nm_table
180
+ @nm_table = nm_table
181
+ @depending_tables = []
182
+ @depending_materialized_views = depending_materialized_views
183
+ end
184
+
185
+ protected
186
+ def type=(t) @type = t end
187
+ end
188
+
189
+ # A type
190
+ class Type < SchemaObject
191
+ def rank() raise Error, "Abstract method" end
192
+ def array?() false end
193
+ def tuple?() false end
194
+ def value?() false end
195
+
196
+ def initialize(schema, name)
197
+ super(schema, name)
198
+ end
199
+
200
+ # Return array type of self. Schema defaults to the schema of self
201
+ def array_type(schema = self.schema)
202
+ array_name = PgGraph.inflector.type2array(name)
203
+ @array_type ||= schema[array_name] || ArrayType.new(schema, nil, self)
204
+ end
205
+ end
206
+
207
+ # TODO: Rename to CatalogType or PgType or PostgresType
208
+ class SimpleType < Type
209
+ alias_method :postgres_type, :key
210
+ attr_reader :ruby_class
211
+ def identifier() ruby_class.to_s end
212
+
213
+ def rank() 0 end
214
+ def value?() true end
215
+
216
+ def initialize(schema, postgres_type)
217
+ super(schema, postgres_type)
218
+ @ruby_class = PgGraph.inflector.postgres_type2ruby_class(postgres_type)
219
+ end
220
+ end
221
+
222
+ class CompositeType < Type
223
+ alias_method :fields, :values
224
+ def rank() 1 end
225
+ def tuple?() true end
226
+ end
227
+
228
+ class RecordType < CompositeType
229
+ include TableObject
230
+
231
+ # List of postgres columns. The columns are sorted by ordinal
232
+ def columns()
233
+ @columns ||= begin
234
+ cols = fields.map { |field|
235
+ case field
236
+ when SimpleColumn; field
237
+ when KindRecordColumn;
238
+ if field.kind_column&.parent
239
+ nil
240
+ else
241
+ field.kind_column
242
+ end
243
+ else
244
+ nil
245
+ end
246
+ }.compact.sort_by(&:ordinal)
247
+ # @columns_hash = @columns.map { |column| [column.name, column] }.to_h
248
+ cols
249
+ end
250
+ end
251
+
252
+ def postgres_columns()
253
+ @postgres_columns ||= begin
254
+ cols = fields.map { |field|
255
+ case field
256
+ when SimpleColumn; field
257
+ when KindRecordColumn;
258
+ if field.kind_column&.parent
259
+ nil
260
+ else
261
+ field.kind_column
262
+ end
263
+ else
264
+ nil
265
+ end
266
+ }.compact.sort_by(&:ordinal)
267
+ # @columns_ hash = @columns.map { |column| [column.name, column] }.to_h
268
+ cols
269
+ end
270
+ end
271
+
272
+ # TODO: Rename #columns to #postgres_columns and #abstract_columns to #columns
273
+ def abstract_columns
274
+ @abstract_columns ||= fields.map { |field|
275
+ case field
276
+ when SimpleColumn; field
277
+ when KindRecordColumn;
278
+ field.kind_column if field.kind_column.parent.nil?
279
+ else
280
+ nil
281
+ end
282
+ }.compact.sort_by(&:ordinal)
283
+
284
+ end
285
+
286
+ # field.is_a?(SimpleColumn) || field.is_a?(KindReference}.sort_by(&:ordinal) end
287
+
288
+ # List of columns excluding generated fields
289
+ def value_columns()
290
+ columns.select { |column| !column.generated? }
291
+ end
292
+
293
+ # True iff name if the name of a postgres column
294
+ # def column?(name) self[name].is_a?(SimpleColumn) end
295
+ def column?(name)
296
+ raise "See the rename TODO above"
297
+ @columns_hash.key?(name)
298
+ end
299
+
300
+ # Associated Table object
301
+ attr_reader :table
302
+
303
+ # Redefine array_type
304
+ def array_type() table_type end
305
+
306
+ # A record type has the schema as parent. Note that a record is
307
+ # initialized with a Table object and not a TableType object
308
+ def initialize(table)
309
+ constrain table, Table
310
+ super(table.schema, PgGraph.inflector.table2record_type(table.name))
311
+ @table = table
312
+ table_type = TableType.new(table.schema, self) # FIXME
313
+ @table.send(:type=, table_type)
314
+ end
315
+
316
+ def attach(*args)
317
+ @columns = nil
318
+ super
319
+ end
320
+ end
321
+
322
+ class ArrayType < Type
323
+ DEFAULT_MAX_DIMENSIONS = 5
324
+
325
+ attr_reader :element_type
326
+ attr_reader :dimensions
327
+
328
+ def rank() 2 end
329
+ def array?() true end
330
+
331
+ def initialize(schema, name, element_type, dimensions = 1)
332
+ constrain element_type, Type
333
+ self.class < TableObject or !element_type.is_a?(TableObject) or raise "Illegal element type"
334
+ dimensions <= ArrayType.max_dimensions or raise Error, "Array dimension overflow"
335
+ super(schema, name || PgGraph.inflector.type2array(element_type.name))
336
+ @element_type = element_type
337
+ @dimensions = dimensions
338
+ end
339
+
340
+ def identifier
341
+ "[#{element_type.identifier}]"
342
+ end
343
+
344
+ def schema_identifier
345
+ "[#{element_type.schema_identifier}]"
346
+ end
347
+
348
+ private
349
+ @max_dimensions = DEFAULT_MAX_DIMENSIONS
350
+ def self.max_dimensions() @max_dimensions end
351
+ def self.max_dimensions=(max) @max_dimensions = max end
352
+ end
353
+
354
+ # Note that the name of a TableType object is the record name in brackets
355
+ # while the name of a Table object is the pluralized record name
356
+ class TableType < ArrayType
357
+ include TableObject
358
+
359
+ alias_method :record_type, :element_type
360
+ forward_method :record_type, :keys, :fields, :key?, :[], :columns, :value_columns, :column?
361
+
362
+ def array_type() raise Error, "Array of TableType is not allowed" end
363
+
364
+ def initialize(schema, record_type)
365
+ constrain record_type, RecordType
366
+ super(schema, nil, record_type)
367
+ end
368
+
369
+ def identifier
370
+ "{#{element_type.identifier}}"
371
+ end
372
+
373
+ def schema_identifier
374
+ "{#{element_type.schema_identifier}}"
375
+ end
376
+ end
377
+
378
+ class Field < Node
379
+ alias_method :composite_type, :parent
380
+ forward_method :type, :rank
381
+ attr_reader :type
382
+
383
+ def initialize(composite_type, name, type)
384
+ constrain composite_type, CompositeType, NilClass
385
+ self.class < TableObject or !type.is_a?(TableObject) or raise "Illegal field type"
386
+ super(composite_type, name)
387
+ @type = type
388
+ end
389
+
390
+ def identifier() name end
391
+
392
+ def schema_identifier
393
+ composite_type.schema_identifier + '.' + identifier
394
+ end
395
+ end
396
+
397
+ # A field in a RecordType
398
+ class Column < Field
399
+ include TableObject
400
+
401
+ alias_method :record_type, :parent
402
+
403
+ # nil for TableColumn and SubRecordColumn objects
404
+ attr_reader :postgres_column
405
+
406
+ # Return true/false or nil if postgres_column is nil
407
+ def primary_key?() @postgres_column && @primary_key end
408
+
409
+ # Return true/false or nil if postgres_column is an identity column.
410
+ # Sub-keys are primary keys but not identity keys
411
+ def identity?() @postgres_column && @identity end
412
+
413
+ # Return true/false or nil
414
+ def nullable?() @postgres_column && @nullable end
415
+
416
+ # Return true/false or nil
417
+ def unique?() @postgres_column && @unique end
418
+
419
+ # Return true/false or nil
420
+ def readonly?() @postgres_column && @readonly end
421
+
422
+ # Return true/false or nil
423
+ def generated?() @postgres_column && @generated end
424
+
425
+ # True if column is a reference
426
+ def reference?() @postgres_column && @reference end
427
+
428
+ # True if column is a kind reference
429
+ def kind?() @postgres_column && @kind end
430
+
431
+ protected
432
+ def initialize(
433
+ record_type, name, postgres_column, type,
434
+ primary_key: false, identity: false,
435
+ reference: false, kind: false,
436
+ nullable: true, unique: false, readonly: false, generated: false)
437
+ constrain record_type, RecordType, NilClass
438
+ constrain type, Type
439
+ super(record_type, name, type)
440
+ @postgres_column = postgres_column
441
+ @primary_key = primary_key
442
+ @identity = identity
443
+ @reference = reference
444
+ @kind = kind
445
+ @nullable = nullable
446
+ @unique = unique
447
+ @readonly = readonly
448
+ @generated = generated
449
+ end
450
+ end
451
+
452
+ # Note that this includes array columns (for now)
453
+ class SimpleColumn < Column
454
+ # Ordinal of column in the database
455
+ attr_reader :ordinal
456
+
457
+ forward_method :type, :postgres_type, :ruby_class
458
+
459
+ def initialize(record_type, name, postgres_column, type, **opts)
460
+ constrain type, Type
461
+ !type.is_a?(TableObject) or raise "Illegal field type: #{type.class}"
462
+ @ordinal = opts.delete(:ordinal)
463
+ super
464
+ end
465
+
466
+ # Return true if the ruby literal match the column type
467
+ def literal?(text) text.is_a?(ruby_class) end
468
+ end
469
+
470
+ class RecordColumn < Column
471
+ # Postgres column in the embedding record that links to the referenced
472
+ # record
473
+ attr_reader :this_link_column
474
+
475
+ # Postgres column in the referenced record that links to the embedding
476
+ # record
477
+ attr_reader :that_link_column
478
+
479
+ # TODO: Is postgres_column == this_link_column?
480
+ def initialize(record_type, name, postgres_column, type, this_link_column, that_link_column, **opts)
481
+ constrain type, RecordType, NilClass
482
+ super(record_type, name, postgres_column, type, reference: true, **opts)
483
+ @this_link_column = this_link_column
484
+ @that_link_column = that_link_column
485
+ end
486
+
487
+ forward_method :type, :keys, :fields, :key?, :[], :columns, :column?
488
+
489
+ # Redefine #dot_lookup to make #dot enter the target record
490
+ def dot_lookup(key) type[key] end
491
+ end
492
+
493
+ class KindRecordColumn < RecordColumn
494
+ # A SimpleColumn object. Note that it doesn't have a parent to avoid having
495
+ # two columns with the same name: This structured KindRecordColumn object
496
+ # and the underlying kind column
497
+ attr_reader :kind_column
498
+
499
+ forward_method :kind_column, :literal?
500
+
501
+ def initialize(
502
+ record_type, name, postgres_column, type,
503
+ this_link_column, that_link_column,
504
+ kind_column, **opts)
505
+ constrain kind_column, SimpleColumn
506
+ super(record_type, name, postgres_column, type, this_link_column, that_link_column, kind: true, **opts)
507
+ @kind_column = kind_column
508
+ end
509
+ end
510
+
511
+ # A record in a 1:1 relationship with another record linked by identical IDs.
512
+ # SuperRecordColumn is on the "child" side of the relation because a child
513
+ # has a super record - hence the name
514
+ class SuperRecordColumn < RecordColumn
515
+ def initialize(record_type, name = nil, type)
516
+ name ||= type.name
517
+ super(
518
+ record_type, name, "id", type, "id", "id",
519
+ primary_key: true, nullable: false, unique: true, readonly: true)
520
+ end
521
+ end
522
+
523
+ # SubRecordColumn is on the "parent" side of the relation because a a parent
524
+ # has a sub record - hence the name
525
+ class SubRecordColumn < RecordColumn
526
+ def initialize(record_type, name = nil, type)
527
+ name ||= type.name
528
+ super(
529
+ record_type, name, "id", type, "id", "id",
530
+ primary_key: nil, nullable: nil, unique: nil, readonly: nil)
531
+ end
532
+ end
533
+
534
+ class TableColumn < Column
535
+ # Postgres column in the embedding record that links (directly or
536
+ # indirectly) to the subject table. This is typically the id column
537
+ attr_reader :this_link_column
538
+
539
+ # Postgres column in the subject table that directly or indirectly links to
540
+ # the embedding record. This is typically the '<this-table>_id' column of
541
+ # that table in 1:N relations
542
+ attr_reader :that_link_column
543
+
544
+ forward_method :type, :keys, :fields, :key?, :[], :columns, :column?
545
+
546
+ def initialize(record_type, name, type, this_link_column, that_link_column, **opts)
547
+ constrain record_type, RecordType
548
+ constrain type, TableType
549
+ super(record_type, name, nil, type, **opts)
550
+ @this_link_column = this_link_column
551
+ @that_link_column = that_link_column
552
+ end
553
+
554
+ def dot_lookup(key) type[key] end
555
+ end
556
+
557
+ class MmTableColumn < TableColumn
558
+ # This table in the relationship
559
+ alias_method :this_table, :table
560
+ alias_method :this_table_type, :table_type
561
+ alias_method :this_record_type, :record_type
562
+
563
+ # The other table in the relationship
564
+ def that_table() that_table_type.table end
565
+ alias_method :that_table_type, :type
566
+ def that_record_type() that_table_type.record_type end
567
+
568
+ # The link table between the two tables
569
+ def mm_table() mm_table_type.table end
570
+ def mm_record_type() mm_table_type.record_type end
571
+ attr_reader :mm_table_type
572
+
573
+ # Postgres column in the NM table that links to this table. This is
574
+ # typically the '<this-table>_id' column
575
+ attr_reader :this_mm_column
576
+
577
+ # Postgres column in the NM table that links to the embedding record. This
578
+ # is typically the '<that-table>_id' column
579
+ attr_reader :that_mm_column
580
+
581
+ # :unique in this context means that the associated records of this column
582
+ # are unique
583
+ def initialize(
584
+ record_type, name, that_table_type, mm_table_type,
585
+ this_link_column, this_mm_column, that_mm_column, that_link_column,
586
+ **opts)
587
+
588
+ # puts "MmTableColumn#initialize"
589
+ # indent {
590
+ # puts "record: #{record_type}"
591
+ # puts "column: #{name}"
592
+ # puts this_mm_column
593
+ # puts this_link_column
594
+ # puts that_mm_column
595
+ # puts that_link_column
596
+ # }
597
+
598
+ constrain record_type, RecordType
599
+ constrain that_table_type, TableType
600
+ constrain mm_table_type, TableType
601
+ mm_table_type.table.mm_table? or raise Error, "Link table expected"
602
+ super(record_type, name, that_table_type, this_link_column, that_link_column, **opts)
603
+ @mm_table_type = mm_table_type
604
+ @this_mm_column = this_mm_column
605
+ @that_mm_column = that_mm_column
606
+ end
607
+ end
608
+
609
+ class NmTableColumn < MmTableColumn
610
+ alias_method :nm_table, :mm_table
611
+ alias_method :nm_record_type, :mm_record_type
612
+ alias_method :nm_table_type, :mm_table_type
613
+ alias_method :this_nm_column, :this_mm_column
614
+ alias_method :that_nm_column, :that_mm_column
615
+ end
616
+ end
617
+
data/pg_graph.gemspec ADDED
@@ -0,0 +1,40 @@
1
+ require_relative 'lib/pg_graph/version'
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "pg_graph"
5
+ spec.version = PgGraph::VERSION
6
+ spec.authors = ["Claus Rasmussen"]
7
+ spec.email = ["claus.l.rasmussen@gmail.com"]
8
+
9
+ spec.summary = %q{Create graph type model of database}
10
+ spec.description = %q{dwpg_graph gem}
11
+ spec.homepage = "http://www.nowhere.com/"
12
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
13
+
14
+
15
+ spec.metadata["homepage_uri"] = spec.homepage
16
+
17
+ # Specify which files should be added to the gem when it is released.
18
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
19
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
20
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
21
+ end
22
+ spec.bindir = "exe"
23
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
24
+ spec.require_paths = ["lib"]
25
+
26
+ # spec.add_dependency GEM [, VERSION]
27
+ spec.add_dependency "boolean"
28
+ spec.add_dependency "constrain"
29
+ spec.add_dependency "developer_exceptions"
30
+ spec.add_dependency "dry-inflector"
31
+ spec.add_dependency "hash_tree", "= 0.1.1"
32
+ spec.add_dependency "indented_io"
33
+ spec.add_dependency "shellopts", "2.0.6"
34
+
35
+ spec.add_dependency "pg_conn", "= 0.2.1"
36
+ spec.add_dependency "pg_meta", "0.1.0"
37
+
38
+ # Also un-comment in spec/spec_helper to use simplecov
39
+ # spec.add_development_dependency "simplecov"
40
+ end
data/snippets/1-1.sql ADDED
@@ -0,0 +1,19 @@
1
+
2
+ \connect postgres
3
+
4
+ drop database if exists pg_graph;
5
+ create database pg_graph;
6
+
7
+ \connect pg_graph
8
+
9
+ create table pictures (
10
+ id integer generated by default as identity primary key,
11
+ title text not null,
12
+ file text
13
+ );
14
+
15
+ create table blobs (
16
+ id integer not null references pictures(id) primary key,
17
+ blob text not null
18
+ );
19
+
data/snippets/N-M.sql ADDED
@@ -0,0 +1,24 @@
1
+
2
+ \connect postgres
3
+
4
+ drop database if exists pg_graph;
5
+ create database pg_graph;
6
+
7
+ \connect pg_graph
8
+
9
+ create table roles (
10
+ id integer generated by default as identity primary key,
11
+ name text not null
12
+ );
13
+
14
+ create table users (
15
+ id integer generated by default as identity primary key,
16
+ name text not null
17
+ );
18
+
19
+ create table user_roles (
20
+ id integer generated by default as identity primary key,
21
+ role_id integer not null references roles(id),
22
+ user_id integer not null references users(id)
23
+ );
24
+