schema-evolution-manager 0.9.24
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 +7 -0
- data/README.md +334 -0
- data/bin/sem-add +46 -0
- data/bin/sem-apply +41 -0
- data/bin/sem-baseline +34 -0
- data/bin/sem-config +1 -0
- data/bin/sem-dist +92 -0
- data/bin/sem-info +43 -0
- data/bin/sem-init +95 -0
- data/lib/schema-evolution-manager.rb +26 -0
- data/lib/schema-evolution-manager/apply_util.rb +60 -0
- data/lib/schema-evolution-manager/args.rb +159 -0
- data/lib/schema-evolution-manager/ask.rb +44 -0
- data/lib/schema-evolution-manager/baseline_util.rb +50 -0
- data/lib/schema-evolution-manager/db.rb +104 -0
- data/lib/schema-evolution-manager/install_template.rb +118 -0
- data/lib/schema-evolution-manager/library.rb +176 -0
- data/lib/schema-evolution-manager/migration_file.rb +93 -0
- data/lib/schema-evolution-manager/preconditions.rb +61 -0
- data/lib/schema-evolution-manager/rdoc_usage.rb +37 -0
- data/lib/schema-evolution-manager/script_error.rb +19 -0
- data/lib/schema-evolution-manager/scripts.rb +95 -0
- data/lib/schema-evolution-manager/sem_info.rb +72 -0
- data/lib/schema-evolution-manager/sem_version.rb +9 -0
- data/lib/schema-evolution-manager/template.rb +39 -0
- data/lib/schema-evolution-manager/version.rb +71 -0
- data/scripts/20130318-105434.sql +64 -0
- data/scripts/20130318-105456.sql +135 -0
- data/template/README.md +28 -0
- data/template/dev.rb +12 -0
- metadata +80 -0
@@ -0,0 +1,44 @@
|
|
1
|
+
module SchemaEvolutionManager
|
2
|
+
|
3
|
+
# Simple library to ask user for input, with easy mocakability for
|
4
|
+
# testing
|
5
|
+
class Ask
|
6
|
+
|
7
|
+
TRUE_STRINGS = ['y', 'yes'] unless defined?(TRUE_STRINGS)
|
8
|
+
|
9
|
+
# Asks the user a question. Expects a string back.
|
10
|
+
def Ask.for_string(message, opts={})
|
11
|
+
default = opts.delete(:default)
|
12
|
+
Preconditions.assert_empty_opts(opts)
|
13
|
+
|
14
|
+
final_message = message.dup
|
15
|
+
if default
|
16
|
+
final_message << " [%s] " % default
|
17
|
+
end
|
18
|
+
|
19
|
+
value = nil
|
20
|
+
while value.to_s == ""
|
21
|
+
print final_message
|
22
|
+
value = get_input.strip
|
23
|
+
if value.to_s == "" && default
|
24
|
+
value = default.to_s.strip
|
25
|
+
end
|
26
|
+
end
|
27
|
+
value
|
28
|
+
end
|
29
|
+
|
30
|
+
# Asks the user a question. Returns a boolean. Boolean is defined as
|
31
|
+
# matching the strings 'y' or 'yes', case insensitive
|
32
|
+
def Ask.for_boolean(message)
|
33
|
+
value = Ask.for_string("%s (y/n) " % message)
|
34
|
+
TRUE_STRINGS.include?(value.downcase)
|
35
|
+
end
|
36
|
+
|
37
|
+
# here to help with tests
|
38
|
+
def Ask.get_input
|
39
|
+
STDIN.gets
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module SchemaEvolutionManager
|
2
|
+
|
3
|
+
class BaselineUtil
|
4
|
+
|
5
|
+
def initialize(db, opts={})
|
6
|
+
@dry_run = opts.delete(:dry_run)
|
7
|
+
if @dry_run.nil?
|
8
|
+
@dry_run = true
|
9
|
+
end
|
10
|
+
|
11
|
+
@db = Preconditions.assert_class(db, Db)
|
12
|
+
@scripts = Scripts.new(@db, Scripts::SCRIPTS)
|
13
|
+
end
|
14
|
+
|
15
|
+
def dry_run?
|
16
|
+
@dry_run
|
17
|
+
end
|
18
|
+
|
19
|
+
# Applies scripts in order, returning number of scripts applied
|
20
|
+
def apply!(dir)
|
21
|
+
Preconditions.check_state(File.directory?(dir),
|
22
|
+
"Dir[%s] does not exist" % dir)
|
23
|
+
|
24
|
+
count = 0
|
25
|
+
@scripts.each_pending(dir) do |filename, path|
|
26
|
+
count += 1
|
27
|
+
if @dry_run
|
28
|
+
puts "[DRY RUN] Baselining #{filename}"
|
29
|
+
apply_dry_run(filename, path)
|
30
|
+
else
|
31
|
+
puts "Baselining #{filename}"
|
32
|
+
apply_real(filename, path)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
count
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
def apply_dry_run(filename, path)
|
40
|
+
puts path
|
41
|
+
puts ""
|
42
|
+
end
|
43
|
+
|
44
|
+
def apply_real(filename, path)
|
45
|
+
@scripts.record_as_run!(filename)
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
module SchemaEvolutionManager
|
2
|
+
|
3
|
+
class Db
|
4
|
+
|
5
|
+
attr_reader :url
|
6
|
+
|
7
|
+
def initialize(url)
|
8
|
+
@url = Preconditions.check_not_blank(url, "url cannot be blank")
|
9
|
+
end
|
10
|
+
|
11
|
+
# Installs schema_evolution_manager. Automatically upgrades schema_evolution_manager.
|
12
|
+
def bootstrap!
|
13
|
+
scripts = Scripts.new(self, Scripts::BOOTSTRAP_SCRIPTS)
|
14
|
+
dir = File.join(Library.base_dir, "scripts")
|
15
|
+
scripts.each_pending(dir) do |filename, path|
|
16
|
+
psql_file(path)
|
17
|
+
scripts.record_as_run!(filename)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# executes a simple sql command.
|
22
|
+
def psql_command(sql_command)
|
23
|
+
Preconditions.assert_class(sql_command, String)
|
24
|
+
command = "psql --no-align --tuples-only --command \"%s\" %s" % [sql_command, @url]
|
25
|
+
Library.system_or_error(command)
|
26
|
+
end
|
27
|
+
|
28
|
+
def Db.attribute_values(path)
|
29
|
+
Preconditions.assert_class(path, String)
|
30
|
+
|
31
|
+
options = []
|
32
|
+
|
33
|
+
['quiet', 'no-align', 'tuples-only'].each do |v|
|
34
|
+
options << "--#{v}"
|
35
|
+
end
|
36
|
+
|
37
|
+
SchemaEvolutionManager::MigrationFile.new(path).attribute_values.map do |value|
|
38
|
+
if value.attribute.name == "transaction"
|
39
|
+
if value.value == "single"
|
40
|
+
options << "--single-transaction"
|
41
|
+
elsif value.value == "none"
|
42
|
+
# No-op
|
43
|
+
else
|
44
|
+
raise "File[%s] - attribute[%s] unsupported value[%s]" % [path, value.attribute.name, value.value]
|
45
|
+
end
|
46
|
+
else
|
47
|
+
raise "File[%s] - unsupported attribute named[%s]" % [path, value.attribute.name]
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
options
|
52
|
+
end
|
53
|
+
|
54
|
+
# executes sql commands from a file in a single transaction
|
55
|
+
def psql_file(path)
|
56
|
+
Preconditions.assert_class(path, String)
|
57
|
+
Preconditions.check_state(File.exists?(path), "File[%s] not found" % path)
|
58
|
+
|
59
|
+
options = Db.attribute_values(path).join(" ")
|
60
|
+
|
61
|
+
Library.with_temp_file(:prefix => File.basename(path)) do |tmp|
|
62
|
+
File.open(tmp, "w") do |out|
|
63
|
+
out << "\\set ON_ERROR_STOP true\n\n"
|
64
|
+
out << IO.read(path)
|
65
|
+
end
|
66
|
+
|
67
|
+
command = "psql --file \"%s\" #{options} %s" % [tmp, @url]
|
68
|
+
Library.system_or_error(command)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# True if the specific schema exists; false otherwise
|
73
|
+
def schema_schema_evolution_manager_exists?
|
74
|
+
sql = "select count(*) from pg_namespace where nspname='%s'" % Db.schema_name
|
75
|
+
psql_command(sql).to_i > 0
|
76
|
+
end
|
77
|
+
|
78
|
+
# Parses command line arguments returning an instance of
|
79
|
+
# Db. Exists if invalid config.
|
80
|
+
def Db.parse_command_line_config(arg_string)
|
81
|
+
Preconditions.assert_class(arg_string, String)
|
82
|
+
args = Args.new(arg_string, :optional => ['url', 'host', 'user', 'name', 'port'])
|
83
|
+
Db.from_args(args)
|
84
|
+
end
|
85
|
+
|
86
|
+
def Db.from_args(args)
|
87
|
+
Preconditions.assert_class(args, Args)
|
88
|
+
if args.url
|
89
|
+
Db.new(args.url)
|
90
|
+
else
|
91
|
+
base = "%s:%s/%s" % [args.host || "localhost", args.port || 5432, args.name]
|
92
|
+
url = args.user ? "%s@%s" % [args.user, base] : base
|
93
|
+
Db.new("postgres://" + url)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
# Returns the name of the schema_evolution_manager schema
|
98
|
+
def Db.schema_name
|
99
|
+
"schema_evolution_manager"
|
100
|
+
end
|
101
|
+
|
102
|
+
end
|
103
|
+
|
104
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
module SchemaEvolutionManager
|
2
|
+
|
3
|
+
class InstallTemplate
|
4
|
+
|
5
|
+
def initialize(opts={})
|
6
|
+
@lib_dir = Preconditions.check_not_blank(opts.delete(:lib_dir), "lib_dir is required")
|
7
|
+
@bin_dir = Preconditions.check_not_blank(opts.delete(:bin_dir), "bin_dir is required")
|
8
|
+
Preconditions.assert_empty_opts(opts)
|
9
|
+
end
|
10
|
+
|
11
|
+
# Generates the actual contents of the install file
|
12
|
+
def generate
|
13
|
+
template = Template.new
|
14
|
+
template.add('timestamp', Time.now.to_s)
|
15
|
+
template.add('lib_dir', @lib_dir)
|
16
|
+
template.add('bin_dir', @bin_dir)
|
17
|
+
template.parse(TEMPLATE)
|
18
|
+
end
|
19
|
+
|
20
|
+
def write_to_file(path)
|
21
|
+
puts "Writing %s" % path
|
22
|
+
File.open(path, "w") do |out|
|
23
|
+
out << generate
|
24
|
+
end
|
25
|
+
Library.system_or_error("chmod +x %s" % path)
|
26
|
+
end
|
27
|
+
|
28
|
+
if !defined?(TEMPLATE)
|
29
|
+
TEMPLATE = <<-"EOS"
|
30
|
+
#!/usr/bin/env ruby
|
31
|
+
#
|
32
|
+
# Generated on %%timestamp%%
|
33
|
+
#
|
34
|
+
|
35
|
+
load File.join(File.dirname(__FILE__), 'lib/schema-evolution-manager.rb')
|
36
|
+
SchemaEvolutionManager::Library.set_verbose(true)
|
37
|
+
|
38
|
+
lib_dir = '%%lib_dir%%'
|
39
|
+
bin_dir = '%%bin_dir%%'
|
40
|
+
version = SchemaEvolutionManager::Version.read
|
41
|
+
|
42
|
+
version_name = "schema-evolution-manager-%s" % version.to_version_string
|
43
|
+
version_dir = File.join(lib_dir, version_name)
|
44
|
+
version_bin_dir = File.join(version_dir, 'bin')
|
45
|
+
|
46
|
+
SchemaEvolutionManager::Library.ensure_dir!(version_dir)
|
47
|
+
SchemaEvolutionManager::Library.ensure_dir!(bin_dir)
|
48
|
+
|
49
|
+
Dir.chdir(lib_dir) do
|
50
|
+
if File.exists?("schema-evolution-manager")
|
51
|
+
if File.symlink?("schema-evolution-manager")
|
52
|
+
SchemaEvolutionManager::Library.system_or_error("rm schema-evolution-manager")
|
53
|
+
SchemaEvolutionManager::Library.system_or_error("ln -s %s %s" % [version_name, 'schema-evolution-manager'])
|
54
|
+
else
|
55
|
+
puts "*** WARNING: File[%s] already exists. Not creating symlink" % File.join(lib_dir, "schema-evolution-manager")
|
56
|
+
end
|
57
|
+
else
|
58
|
+
SchemaEvolutionManager::Library.system_or_error("ln -s %s %s" % [version_name, 'schema-evolution-manager'])
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
['CONVENTIONS.md', 'LICENSE', 'README.md', 'VERSION'].each do |filename|
|
63
|
+
SchemaEvolutionManager::Library.system_or_error("cp %s %s" % [filename, version_dir])
|
64
|
+
end
|
65
|
+
|
66
|
+
['bin', 'lib', 'lib/schema-evolution-manager', 'template', 'scripts'].each do |dir|
|
67
|
+
this_dir = File.join(version_dir, dir)
|
68
|
+
SchemaEvolutionManager::Library.ensure_dir!(this_dir)
|
69
|
+
Dir.foreach(dir) do |filename|
|
70
|
+
path = File.join(dir, filename)
|
71
|
+
if File.file?(path)
|
72
|
+
SchemaEvolutionManager::Library.system_or_error("cp %s %s" % [path, this_dir])
|
73
|
+
if dir == "bin" && filename != "sem-config"
|
74
|
+
SchemaEvolutionManager::Library.system_or_error("chmod +x %s/%s" % [this_dir, filename])
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
aliased_bin_dir = File.join(lib_dir, "schema-evolution-manager", "bin")
|
81
|
+
Dir.chdir(bin_dir) do
|
82
|
+
Dir.foreach(aliased_bin_dir) do |filename|
|
83
|
+
path = File.join(aliased_bin_dir, filename)
|
84
|
+
if File.file?(path)
|
85
|
+
SchemaEvolutionManager::Library.system_or_error("rm -f %s && ln -s %s" % [filename, path])
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Overrwrite bin/sem-config with proper location of lib dir
|
91
|
+
init_file = File.join(version_dir, "bin/sem-config")
|
92
|
+
SchemaEvolutionManager::Preconditions.check_state(File.exists?(init_file), "Init file[%s] not found" % init_file)
|
93
|
+
File.open(init_file, "w") do |out|
|
94
|
+
out << "load File.join('%s')\n" % File.join(version_dir, 'lib/schema-evolution-manager.rb')
|
95
|
+
end
|
96
|
+
|
97
|
+
puts ""
|
98
|
+
puts "schema-evolution-manager %s installed in %s" % [version.to_version_string, version_dir]
|
99
|
+
puts " - lib dir: %s" % lib_dir
|
100
|
+
puts " - bin dir: %s" % bin_dir
|
101
|
+
puts ""
|
102
|
+
|
103
|
+
found = `which sem-add`.strip
|
104
|
+
if found == ""
|
105
|
+
puts "Recommend adding the bin directory to your path"
|
106
|
+
puts " export PATH=%s:$PATH" % bin_dir
|
107
|
+
puts ""
|
108
|
+
end
|
109
|
+
|
110
|
+
puts "installation complete"
|
111
|
+
puts ""
|
112
|
+
|
113
|
+
EOS
|
114
|
+
end
|
115
|
+
|
116
|
+
end
|
117
|
+
|
118
|
+
end
|
@@ -0,0 +1,176 @@
|
|
1
|
+
module SchemaEvolutionManager
|
2
|
+
class Library
|
3
|
+
|
4
|
+
unless defined?(TMPFILE_DIR)
|
5
|
+
TMPFILE_DIR = "/tmp"
|
6
|
+
TMPFILE_PREFIX = "schema-evolution-manager-#{Process.pid}.tmp"
|
7
|
+
end
|
8
|
+
@@tmpfile_count = 0
|
9
|
+
@@verbose = false
|
10
|
+
|
11
|
+
# Creates the dir if it does not already exist
|
12
|
+
def Library.ensure_dir!(dir)
|
13
|
+
Preconditions.assert_class(dir, String)
|
14
|
+
|
15
|
+
if !File.directory?(dir)
|
16
|
+
Library.system_or_error("mkdir -p #{dir}")
|
17
|
+
end
|
18
|
+
Library.assert_dir_exists(dir)
|
19
|
+
end
|
20
|
+
|
21
|
+
def Library.assert_dir_exists(dir)
|
22
|
+
Preconditions.assert_class(dir, String)
|
23
|
+
|
24
|
+
if !File.directory?(dir)
|
25
|
+
raise "Dir[#{dir}] does not exist"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def Library.format_time(timestamp=Time.now)
|
30
|
+
timestamp.strftime('%Y-%m-%d %H:%M:%S %Z')
|
31
|
+
end
|
32
|
+
|
33
|
+
def Library.git_assert_tag_exists(tag)
|
34
|
+
command = "git tag -l"
|
35
|
+
results = Library.system_or_error(command)
|
36
|
+
if results.nil?
|
37
|
+
raise "No git tags found"
|
38
|
+
end
|
39
|
+
|
40
|
+
if !Library.tag_exists?(tag)
|
41
|
+
raise "Tag[#{tag}] not found. Check #{command}"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def Library.assert_valid_tag(tag)
|
46
|
+
Preconditions.check_state(Version.is_valid?(tag), "Invalid tag[%s]. Format must be x.x.x (e.g. 1.1.2)" % tag)
|
47
|
+
end
|
48
|
+
|
49
|
+
def Library.git_has_remote?
|
50
|
+
system("git config --get remote.origin.url")
|
51
|
+
end
|
52
|
+
|
53
|
+
# Fetches the latest tag from the git repo. Returns nil if there are
|
54
|
+
# no tags, otherwise returns an instance of Version. Only searches for
|
55
|
+
# tags matching x.x.x (e.g. 1.0.2)
|
56
|
+
def Library.latest_tag
|
57
|
+
`git tag -l`.strip.split.select { |tag| Version.is_valid?(tag) }.map { |tag| Version.new(tag) }.sort.last
|
58
|
+
end
|
59
|
+
|
60
|
+
def Library.tag_exists?(tag)
|
61
|
+
`git tag -l`.strip.include?(tag)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Ex: Library.git_create_tag("0.0.1")
|
65
|
+
def Library.git_create_tag(tag)
|
66
|
+
Library.assert_valid_tag(tag)
|
67
|
+
has_remote = Library.git_has_remote?
|
68
|
+
if has_remote
|
69
|
+
Library.system_or_error("git fetch --tags origin")
|
70
|
+
end
|
71
|
+
Library.system_or_error("git tag -a -m #{tag} #{tag}")
|
72
|
+
if has_remote
|
73
|
+
Library.system_or_error("git push --tags origin")
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Generates a temp file name, yield the full path to the
|
78
|
+
# file. Cleans up automatically on exit.
|
79
|
+
def Library.with_temp_file(opts={})
|
80
|
+
prefix = opts.delete(:prefix)
|
81
|
+
Preconditions.assert_empty_opts(opts)
|
82
|
+
|
83
|
+
if prefix.to_s == ""
|
84
|
+
prefix = TMPFILE_PREFIX
|
85
|
+
end
|
86
|
+
path = File.join(TMPFILE_DIR, "%s.%s" % [prefix, @@tmpfile_count])
|
87
|
+
@@tmpfile_count += 1
|
88
|
+
yield path
|
89
|
+
ensure
|
90
|
+
Library.delete_file_if_exists(path)
|
91
|
+
end
|
92
|
+
|
93
|
+
def Library.delete_file_if_exists(path)
|
94
|
+
if File.exists?(path)
|
95
|
+
FileUtils.rm_r(path)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# Writes the string to a temp file, yielding the path. Cleans up on
|
100
|
+
# exit.
|
101
|
+
def Library.write_to_temp_file(string)
|
102
|
+
Library.with_temp_file do |path|
|
103
|
+
File.open(path, "w") do |out|
|
104
|
+
out << string
|
105
|
+
end
|
106
|
+
yield path
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
# Returns the relative path to the base directory (root of this git
|
111
|
+
# repo)
|
112
|
+
def Library.base_dir
|
113
|
+
@@base_dir
|
114
|
+
end
|
115
|
+
|
116
|
+
def Library.set_base_dir(value)
|
117
|
+
Preconditions.check_state(File.directory?(value), "Dir[%s] not found" % value)
|
118
|
+
@@base_dir = Library.normalize_path(value)
|
119
|
+
end
|
120
|
+
|
121
|
+
# Runs the specified command, raising an error if there is a problem
|
122
|
+
# (based on status code of the process executed). Otherwise returns
|
123
|
+
# all the output from the script invoked.
|
124
|
+
def Library.system_or_error(command)
|
125
|
+
if Library.is_verbose?
|
126
|
+
puts command
|
127
|
+
end
|
128
|
+
|
129
|
+
begin
|
130
|
+
result = `#{command}`.strip
|
131
|
+
status = $?
|
132
|
+
if status.to_i > 0
|
133
|
+
raise "Non zero exit code[%s] running command[%s]" % [status, command]
|
134
|
+
end
|
135
|
+
rescue Exception => e
|
136
|
+
raise "Error running command[%s]: %s" % [command, e.to_s]
|
137
|
+
end
|
138
|
+
result
|
139
|
+
end
|
140
|
+
|
141
|
+
def Library.normalize_path(path)
|
142
|
+
Pathname.new(path).cleanpath.to_s
|
143
|
+
end
|
144
|
+
|
145
|
+
def Library.is_verbose?
|
146
|
+
@@verbose
|
147
|
+
end
|
148
|
+
|
149
|
+
def Library.set_verbose(value)
|
150
|
+
@@verbose = value ? true : false
|
151
|
+
end
|
152
|
+
|
153
|
+
# Returns a formatted string of git commits made since the specified tag.
|
154
|
+
def Library.git_changes(opts={})
|
155
|
+
tag = opts.delete(:tag)
|
156
|
+
number_changes = opts.delete(:number_changes) || 10
|
157
|
+
Preconditions.check_state(number_changes > 0)
|
158
|
+
Preconditions.assert_empty_opts(opts)
|
159
|
+
|
160
|
+
git_log_command = "git log --pretty=format:\"%h %ad | %s%d [%an]\" --date=short -#{number_changes}"
|
161
|
+
git_log = Library.system_or_error(git_log_command)
|
162
|
+
out = ""
|
163
|
+
out << "Created: %s\n" % Library.format_time
|
164
|
+
if tag
|
165
|
+
out << "Git Tag: %s\n" % tag
|
166
|
+
end
|
167
|
+
out << "\n"
|
168
|
+
out << "%s:\n" % git_log_command
|
169
|
+
out << " " << git_log.split("\n").join("\n ") << "\n"
|
170
|
+
out
|
171
|
+
end
|
172
|
+
|
173
|
+
@@base_dir = Library.normalize_path(File.join(File.dirname(__FILE__), ".."))
|
174
|
+
end
|
175
|
+
|
176
|
+
end
|