sql_beautifier 0.1.4 → 0.3.0

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: 2cd89341ca715477774f461f2f819550e28aea5b10da268f4cb662079b6d6577
4
- data.tar.gz: f9e1cb24ddfcae2290117366a969c14865cb938edb88574cd4e0fd5be1aee439
3
+ metadata.gz: 6df1f55c9242793101e9f562329763541d56aa4f17f7e3d81a12038ab5b53cca
4
+ data.tar.gz: 43fea825ac0c4bded07098814a2ed67e5503e1829f3d47cc99a7c989ec0e69d1
5
5
  SHA512:
6
- metadata.gz: bd61f519fd40401a02d1a8a235a4c4957754f07a0c8862bbfccf464706c0417397c9284215c9c02dadfb21a38e7bd7dc36a0302abf1042fe2931adcf940ae859
7
- data.tar.gz: d76dfa235ca58a94733582e52f991c4997dfe5dee336a8dc7adc4937fc89891c76de6e2bb6d06449c241bdcd416ecdb8f137b31fd9a6513cc490a478c890f8b5
6
+ metadata.gz: d56c72c27d4b71781be0ee83ba44fb3a4e466567c5edbde267c2cfbeb245c81e78bc52a309d27e7dae69a2a7e3cbf1bb3ce55926a266a6c914e822140b6650b7
7
+ data.tar.gz: 9e2e5118b5b12e52551300e2c0219c6cc42907fdb87fd56a8e6e80e193b212ae36ff87ee48d2a0135dc0140121d145c3f104622a0efbd0a3bbeffe10af378cea
data/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [X.X.X] - YYYY-MM-DD
4
4
 
5
+ ## [0.3.0] - 2026-03-27
6
+
7
+ - Add configuration system with `SqlBeautifier.configure` block and `SqlBeautifier.reset_configuration!`
8
+ - Add configurable keyword case (`:lower` / `:upper`), keyword column width, indent spaces, table name format (`:pascal_case` / `:lowercase`), inline group threshold, and alias strategy (`:initials` / `:none` / callable)
9
+ - Add semicolon stripping in normalizer (trailing `;` removed before formatting)
10
+ - Add comment stripping in normalizer (`--` line comments and `/* */` block comments, string-aware)
11
+ - Add subquery formatting with recursive indentation (`(select ...)` expanded to multiline)
12
+
13
+ ## [0.2.0] - 2026-03-27
14
+
15
+ - Add JOIN support (inner, left, right, full outer, cross) with formatted continuation lines
16
+ - Add automatic table aliasing using initials (e.g. `users` → `u`, `active_storage_blobs` → `asb`)
17
+ - Add PascalCase table name formatting (e.g. `users` → `Users`, `user_sessions` → `User_Sessions`)
18
+ - Add `table.column` → `alias.column` replacement across full output
19
+ - Add DISTINCT and DISTINCT ON support in SELECT clause
20
+ - Add AND/OR condition formatting in WHERE and HAVING clauses with per-line indentation
21
+ - Add parenthesized condition group handling (inline when short, expanded when long)
22
+
5
23
  ## [0.1.4] - 2026-03-26
6
24
 
7
25
  - Update `bin/ci` and `bin/release` to use new formatting functions
data/README.md CHANGED
@@ -41,14 +41,149 @@ select id,
41
41
  name,
42
42
  email
43
43
 
44
- from users
44
+ from Users u
45
45
 
46
46
  where active = true
47
47
 
48
48
  order by name
49
49
  ```
50
50
 
51
- Single-word keywords are lowercased and padded so their clause bodies start at an 8-character column. Multi-word clauses such as `order by` and `group by`, and short clauses like `limit`, use a single space between the keyword and the clause body instead of padding. Each clause is separated by a blank line. Multi-column SELECT lists place each column on its own line with continuation indentation.
51
+ Single-word keywords are lowercased and padded so their clause bodies start at an 8-character column. Multi-word clauses such as `order by` and `group by`, and short clauses like `limit`, use a single space between the keyword and the clause body instead of padding. Clause spacing is compact by default for simple one-column / one-table / one-condition queries, and otherwise uses blank lines between top-level clauses. Multi-column SELECT lists place each column on its own line with continuation indentation. Table names are PascalCased and automatically aliased.
52
+
53
+ ### Table Aliasing
54
+
55
+ Tables are automatically aliased using their initials. Underscore-separated table names use the first letter of each segment:
56
+
57
+ | Table Name | PascalCase | Alias |
58
+ | -------------------------- | -------------------------- | ----- |
59
+ | `users` | `Users` | `u` |
60
+ | `active_storage_blobs` | `Active_Storage_Blobs` | `asb` |
61
+ | `person_event_invitations` | `Person_Event_Invitations` | `pei` |
62
+
63
+ All `table.column` references throughout the query are replaced with `alias.column`:
64
+
65
+ ```ruby
66
+ SqlBeautifier.call("SELECT users.id, users.name FROM users WHERE users.active = true")
67
+ ```
68
+
69
+ Produces:
70
+
71
+ ```sql
72
+ select u.id,
73
+ u.name
74
+
75
+ from Users u
76
+
77
+ where u.active = true
78
+ ```
79
+
80
+ When two tables produce the same initials, a counter is appended for disambiguation (e.g. `u1`, `u2`).
81
+
82
+ ### JOINs
83
+
84
+ JOIN clauses are formatted on continuation-indented lines with PascalCase table names and aliases. Multi-condition JOINs place additional conditions on further-indented lines:
85
+
86
+ ```ruby
87
+ SqlBeautifier.call(<<~SQL)
88
+ SELECT users.id, orders.total, products.name
89
+ FROM users
90
+ INNER JOIN orders ON orders.user_id = users.id
91
+ INNER JOIN products ON products.id = orders.product_id
92
+ WHERE users.active = true AND orders.total > 100
93
+ ORDER BY orders.total DESC
94
+ SQL
95
+ ```
96
+
97
+ Produces:
98
+
99
+ ```sql
100
+ select u.id,
101
+ o.total,
102
+ p.name
103
+
104
+ from Users u
105
+ inner join Orders o on o.user_id = u.id
106
+ inner join Products p on p.id = o.product_id
107
+
108
+ where u.active = true
109
+ and o.total > 100
110
+
111
+ order by o.total desc
112
+ ```
113
+
114
+ Supported join types: `inner join`, `left join`, `right join`, `full join`, `left outer join`, `right outer join`, `full outer join`, `cross join`.
115
+
116
+ ### DISTINCT and DISTINCT ON
117
+
118
+ `DISTINCT` is placed on the `select` line as a modifier, with columns on continuation lines:
119
+
120
+ ```ruby
121
+ SqlBeautifier.call("SELECT DISTINCT id, name, email FROM users")
122
+ ```
123
+
124
+ Produces:
125
+
126
+ ```sql
127
+ select distinct
128
+ id,
129
+ name,
130
+ email
131
+
132
+ from Users u
133
+ ```
134
+
135
+ `DISTINCT ON` preserves the full expression:
136
+
137
+ ```ruby
138
+ SqlBeautifier.call("SELECT DISTINCT ON (user_id) id, name FROM events")
139
+ ```
140
+
141
+ Produces:
142
+
143
+ ```sql
144
+ select distinct on (user_id)
145
+ id,
146
+ name
147
+
148
+ from Events e
149
+ ```
150
+
151
+ ### WHERE and HAVING Conditions
152
+
153
+ Multiple conditions in WHERE and HAVING clauses are formatted with each condition on its own line:
154
+
155
+ ```ruby
156
+ SqlBeautifier.call("SELECT * FROM users WHERE active = true AND role = 'admin' AND created_at > '2024-01-01'")
157
+ ```
158
+
159
+ Produces:
160
+
161
+ ```sql
162
+ select *
163
+
164
+ from Users u
165
+
166
+ where active = true
167
+ and role = 'admin'
168
+ and created_at > '2024-01-01'
169
+ ```
170
+
171
+ Short parenthesized groups stay inline:
172
+
173
+ ```ruby
174
+ SqlBeautifier.call("SELECT * FROM users WHERE active = true AND (role = 'admin' OR role = 'moderator')")
175
+ ```
176
+
177
+ Produces:
178
+
179
+ ```sql
180
+ select *
181
+
182
+ from Users u
183
+
184
+ where active = true
185
+ and (role = 'admin' or role = 'moderator')
186
+ ```
52
187
 
53
188
  ### GROUP BY and HAVING
54
189
 
@@ -67,7 +202,7 @@ Produces:
67
202
  select status,
68
203
  count(*)
69
204
 
70
- from users
205
+ from Users u
71
206
 
72
207
  group by status
73
208
 
@@ -84,11 +219,8 @@ Produces:
84
219
 
85
220
  ```sql
86
221
  select id
87
-
88
- from users
89
-
222
+ from Users u
90
223
  order by created_at desc
91
-
92
224
  limit 25
93
225
  ```
94
226
 
@@ -105,9 +237,10 @@ Produces:
105
237
  ```sql
106
238
  select *
107
239
 
108
- from users
240
+ from Users u
109
241
 
110
- where name = 'O''Brien' and status = 'Active'
242
+ where name = 'O''Brien'
243
+ and status = 'Active'
111
244
  ```
112
245
 
113
246
  ### Double-Quoted Identifiers
@@ -124,7 +257,88 @@ Produces:
124
257
  select user_id,
125
258
  full_name
126
259
 
127
- from users
260
+ from Users u
261
+ ```
262
+
263
+ ### Subqueries
264
+
265
+ Subqueries are automatically detected and recursively formatted with indentation:
266
+
267
+ ```ruby
268
+ SqlBeautifier.call("SELECT id FROM users WHERE id IN (SELECT user_id FROM orders WHERE total > 100)")
269
+ ```
270
+
271
+ Produces:
272
+
273
+ ```sql
274
+ select id
275
+ from Users u
276
+ where id in (
277
+ select user_id
278
+ from Orders o
279
+ where total > 100
280
+ )
281
+ ```
282
+
283
+ Nested subqueries increase indentation at each level.
284
+
285
+ ### Comments and Semicolons
286
+
287
+ SQL comments (`--` line comments and `/* */` block comments) and trailing semicolons are automatically stripped during normalization. Comments inside string literals are preserved:
288
+
289
+ ```ruby
290
+ SqlBeautifier.call("SELECT id /* primary key */ FROM users -- main table\nWHERE active = true;")
291
+ ```
292
+
293
+ Produces:
294
+
295
+ ```sql
296
+ select id
297
+ from Users u
298
+ where active = true
299
+ ```
300
+
301
+ ### Configuration
302
+
303
+ Customize formatting behavior with `SqlBeautifier.configure`:
304
+
305
+ ```ruby
306
+ SqlBeautifier.configure do |config|
307
+ config.keyword_case = :upper # :lower (default), :upper
308
+ config.keyword_column_width = 10 # default: 8
309
+ config.indent_spaces = 4 # default: 4
310
+ config.clause_spacing_mode = :spacious # :compact (default), :spacious
311
+ config.table_name_format = :lowercase # :pascal_case (default), :lowercase
312
+ config.inline_group_threshold = 80 # default: 100
313
+ config.alias_strategy = :none # :initials (default), :none, or a callable
314
+ end
315
+ ```
316
+
317
+ #### Clause Spacing Modes
318
+
319
+ - `:compact` (default) keeps top-level clauses on single newlines only when the query is simple:
320
+ - exactly one SELECT column
321
+ - exactly one FROM table (no JOINs)
322
+ - zero or one top-level WHERE condition
323
+ - only `select`, `from`, optional `where`, optional `order by`, and optional `limit`
324
+ - `:spacious` always separates top-level clauses with blank lines
325
+
326
+ Reset to defaults:
327
+
328
+ ```ruby
329
+ SqlBeautifier.reset_configuration!
330
+ ```
331
+
332
+ #### Alias Strategies
333
+
334
+ - `:initials` (default) — automatic aliases using table initials (`users` → `u`, `active_storage_blobs` → `asb`)
335
+ - `:none` — no automatic aliases (explicit aliases in the SQL are still preserved)
336
+ - Callable — provide a proc/lambda for custom alias generation:
337
+
338
+ ```ruby
339
+ SqlBeautifier.configure do |config|
340
+ config.alias_strategy = ->(table_name) { "t_#{table_name[0..2]}" }
341
+ end
128
342
  ```
129
343
 
130
344
  ### Callable Interface
@@ -10,6 +10,16 @@ module SqlBeautifier
10
10
  def initialize(value)
11
11
  @value = value
12
12
  end
13
+
14
+ private
15
+
16
+ def keyword_prefix
17
+ Util.keyword_padding(self.class::KEYWORD)
18
+ end
19
+
20
+ def continuation_indent
21
+ Util.continuation_padding
22
+ end
13
23
  end
14
24
  end
15
25
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlBeautifier
4
+ module Clauses
5
+ class ConditionClause < Base
6
+ def call
7
+ return "#{keyword_prefix}#{@value.strip}" unless multiple_conditions?
8
+
9
+ formatted_conditions = ConditionFormatter.format(@value, indent_width: SqlBeautifier.config_for(:keyword_column_width))
10
+ formatted_conditions.sub(continuation_indent, keyword_prefix)
11
+ end
12
+
13
+ private
14
+
15
+ def multiple_conditions?
16
+ Tokenizer.split_top_level_conditions(@value).length > 1
17
+ end
18
+ end
19
+ end
20
+ end
@@ -3,10 +3,147 @@
3
3
  module SqlBeautifier
4
4
  module Clauses
5
5
  class From < Base
6
- KEYWORD_PREFIX = "from "
6
+ KEYWORD = "from"
7
+
8
+ def self.call(value, table_registry:)
9
+ new(value, table_registry: table_registry).call
10
+ end
11
+
12
+ def initialize(value, table_registry:)
13
+ super(value)
14
+ @table_registry = table_registry
15
+ end
7
16
 
8
17
  def call
9
- "#{KEYWORD_PREFIX}#{@value.strip}"
18
+ @lines = []
19
+
20
+ join_parts = split_join_parts
21
+ primary_table_text = join_parts.shift.strip
22
+ formatted_primary_table_name = format_table_with_alias(primary_table_text)
23
+ add_line!("#{keyword_prefix}#{formatted_primary_table_name}")
24
+
25
+ join_parts.each { |join_part| format_join_part(join_part) }
26
+
27
+ @lines.join("\n")
28
+ end
29
+
30
+ private
31
+
32
+ def add_line!(line)
33
+ @lines << line
34
+ end
35
+
36
+ def join_condition_indentation
37
+ Util.whitespace(SqlBeautifier.config_for(:keyword_column_width) + 4)
38
+ end
39
+
40
+ def format_join_part(join_part)
41
+ join_keyword, remaining_join_content = extract_join_keyword(join_part)
42
+ return unless join_keyword && remaining_join_content
43
+
44
+ on_keyword_position = Tokenizer.find_top_level_keyword(remaining_join_content, "on")
45
+
46
+ if on_keyword_position
47
+ format_join_with_conditions(join_keyword, remaining_join_content, on_keyword_position)
48
+ else
49
+ formatted_table_name = format_table_with_alias(remaining_join_content)
50
+ add_line!("#{continuation_indent}#{join_keyword} #{formatted_table_name}")
51
+ end
52
+ end
53
+
54
+ def format_join_with_conditions(join_keyword, join_content, on_keyword_position)
55
+ table_text = join_content[0...on_keyword_position].strip
56
+ condition_text = join_content[on_keyword_position..].delete_prefix("on").strip
57
+ on_conditions = Tokenizer.split_top_level_conditions(condition_text)
58
+
59
+ formatted_table_name = format_table_with_alias(table_text)
60
+ first_condition = on_conditions.first[1]
61
+ add_line!("#{continuation_indent}#{join_keyword} #{formatted_table_name} on #{first_condition}")
62
+
63
+ on_conditions.drop(1).each do |conjunction, additional_condition|
64
+ add_line!("#{join_condition_indentation}#{conjunction} #{additional_condition}")
65
+ end
66
+ end
67
+
68
+ def split_join_parts
69
+ from_content = @value.strip
70
+ join_keyword_positions = find_all_join_keyword_positions(from_content)
71
+
72
+ return [from_content] if join_keyword_positions.empty?
73
+
74
+ parts = [from_content[0...join_keyword_positions.first[:position]]]
75
+
76
+ join_keyword_positions.each_with_index do |join_info, index|
77
+ end_position = begin
78
+ if index + 1 < join_keyword_positions.length
79
+ join_keyword_positions[index + 1][:position]
80
+ else
81
+ from_content.length
82
+ end
83
+ end
84
+
85
+ parts << from_content[join_info[:position]...end_position]
86
+ end
87
+
88
+ parts
89
+ end
90
+
91
+ def find_all_join_keyword_positions(text)
92
+ positions = []
93
+ search_offset = 0
94
+
95
+ while search_offset < text.length
96
+ earliest_match = find_earliest_join_keyword(text, search_offset)
97
+ break unless earliest_match
98
+
99
+ positions << earliest_match
100
+ search_offset = earliest_match[:position] + earliest_match[:keyword].length
101
+ end
102
+
103
+ positions
104
+ end
105
+
106
+ def find_earliest_join_keyword(text, search_offset)
107
+ earliest_match = nil
108
+
109
+ Constants::JOIN_KEYWORDS_BY_LENGTH.each do |keyword|
110
+ remaining_text = text[search_offset..]
111
+ keyword_position = Tokenizer.find_top_level_keyword(remaining_text, keyword)
112
+ next unless keyword_position
113
+
114
+ absolute_position = search_offset + keyword_position
115
+ next if earliest_match && absolute_position >= earliest_match[:position]
116
+
117
+ earliest_match = {
118
+ position: absolute_position,
119
+ keyword: keyword,
120
+ }
121
+ end
122
+
123
+ earliest_match
124
+ end
125
+
126
+ def extract_join_keyword(join_part)
127
+ trimmed_join_text = join_part.strip
128
+
129
+ Constants::JOIN_KEYWORDS_BY_LENGTH.each do |keyword|
130
+ next unless trimmed_join_text.downcase.start_with?(keyword)
131
+
132
+ remaining_join_content = trimmed_join_text[keyword.length..].strip
133
+
134
+ return [keyword, remaining_join_content]
135
+ end
136
+
137
+ [nil, nil]
138
+ end
139
+
140
+ def format_table_with_alias(table_text)
141
+ table_name = Util.first_word(table_text)
142
+ formatted_table_name = Util.format_table_name(table_name)
143
+ table_alias = @table_registry.alias_for(table_name)
144
+ return formatted_table_name unless table_alias
145
+
146
+ "#{formatted_table_name} #{table_alias}"
10
147
  end
11
148
  end
12
149
  end
@@ -3,10 +3,10 @@
3
3
  module SqlBeautifier
4
4
  module Clauses
5
5
  class GroupBy < Base
6
- KEYWORD_PREFIX = "group by "
6
+ KEYWORD = "group by"
7
7
 
8
8
  def call
9
- "#{KEYWORD_PREFIX}#{@value.strip}"
9
+ "#{keyword_prefix}#{@value.strip}"
10
10
  end
11
11
  end
12
12
  end
@@ -2,12 +2,8 @@
2
2
 
3
3
  module SqlBeautifier
4
4
  module Clauses
5
- class Having < Base
6
- KEYWORD_PREFIX = "having "
7
-
8
- def call
9
- "#{KEYWORD_PREFIX}#{@value.strip}"
10
- end
5
+ class Having < ConditionClause
6
+ KEYWORD = "having"
11
7
  end
12
8
  end
13
9
  end
@@ -3,10 +3,16 @@
3
3
  module SqlBeautifier
4
4
  module Clauses
5
5
  class Limit < Base
6
- KEYWORD_PREFIX = "limit "
6
+ KEYWORD = "limit"
7
7
 
8
8
  def call
9
- "#{KEYWORD_PREFIX}#{@value.strip}"
9
+ "#{keyword_prefix}#{@value.strip}"
10
+ end
11
+
12
+ private
13
+
14
+ def keyword_prefix
15
+ "#{Util.format_keyword(KEYWORD)} "
10
16
  end
11
17
  end
12
18
  end
@@ -3,10 +3,10 @@
3
3
  module SqlBeautifier
4
4
  module Clauses
5
5
  class OrderBy < Base
6
- KEYWORD_PREFIX = "order by "
6
+ KEYWORD = "order by"
7
7
 
8
8
  def call
9
- "#{KEYWORD_PREFIX}#{@value.strip}"
9
+ "#{keyword_prefix}#{@value.strip}"
10
10
  end
11
11
  end
12
12
  end
@@ -3,18 +3,72 @@
3
3
  module SqlBeautifier
4
4
  module Clauses
5
5
  class Select < Base
6
- KEYWORD_PREFIX = "select "
7
- CONTINUATION_INDENT = " "
6
+ KEYWORD = "select"
7
+ DISTINCT_ON_PARENTHESIS_PATTERN = %r{distinct on\s*\(}
8
+ DISTINCT_ON_PATTERN = %r{distinct on }
9
+ LEADING_COMMA_PATTERN = %r{\A,\s*}
8
10
 
9
11
  def call
10
- columns = Tokenizer.split_by_top_level_commas(@value)
11
- return "#{KEYWORD_PREFIX}#{columns.first.strip}" if columns.length == 1
12
+ prefix, remaining_columns = extract_prefix
13
+ columns = Tokenizer.split_by_top_level_commas(remaining_columns)
12
14
 
13
- column_lines = columns.map { |column| "#{CONTINUATION_INDENT}#{column.strip}" }
14
- column_lines[0] = "#{KEYWORD_PREFIX}#{columns.first.strip}"
15
+ return format_with_prefix(prefix, columns) if prefix
16
+ return keyword_line(columns.first) if columns.length == 1
17
+
18
+ format_columns_list(columns)
19
+ end
20
+
21
+ private
22
+
23
+ def keyword_line(column)
24
+ "#{keyword_prefix}#{column.strip}"
25
+ end
26
+
27
+ def continuation_line(column)
28
+ "#{continuation_indent}#{column.strip}"
29
+ end
30
+
31
+ def format_with_prefix(prefix, columns)
32
+ first_line = "#{keyword_prefix}#{prefix}"
33
+ column_lines = columns.map { |column| continuation_line(column) }
34
+
35
+ "#{first_line}\n#{column_lines.join(",\n")}"
36
+ end
37
+
38
+ def format_columns_list(columns)
39
+ column_lines = columns.map { |column| continuation_line(column) }
40
+ column_lines[0] = keyword_line(columns.first)
15
41
 
16
42
  column_lines.join(",\n")
17
43
  end
44
+
45
+ def extract_prefix
46
+ stripped_value = @value.strip
47
+
48
+ if stripped_value.start_with?("distinct on ")
49
+ extract_distinct_on_prefix(stripped_value)
50
+ elsif stripped_value.start_with?("distinct ")
51
+ remaining_columns = stripped_value.delete_prefix("distinct ").strip
52
+
53
+ ["distinct", remaining_columns]
54
+ else
55
+ [nil, stripped_value]
56
+ end
57
+ end
58
+
59
+ def extract_distinct_on_prefix(stripped_value)
60
+ distinct_on_position = stripped_value.index(DISTINCT_ON_PARENTHESIS_PATTERN) || stripped_value.index(DISTINCT_ON_PATTERN)
61
+ opening_parenthesis_position = stripped_value.index(Constants::OPEN_PARENTHESIS, distinct_on_position)
62
+ return [nil, stripped_value] unless opening_parenthesis_position
63
+
64
+ closing_parenthesis_position = Tokenizer.find_matching_parenthesis(stripped_value, opening_parenthesis_position)
65
+ return [nil, stripped_value] unless closing_parenthesis_position
66
+
67
+ prefix = stripped_value[0..closing_parenthesis_position]
68
+ remaining_columns = stripped_value[(closing_parenthesis_position + 1)..].strip.sub(LEADING_COMMA_PATTERN, "")
69
+
70
+ [prefix, remaining_columns]
71
+ end
18
72
  end
19
73
  end
20
74
  end
@@ -2,12 +2,8 @@
2
2
 
3
3
  module SqlBeautifier
4
4
  module Clauses
5
- class Where < Base
6
- KEYWORD_PREFIX = "where "
7
-
8
- def call
9
- "#{KEYWORD_PREFIX}#{@value.strip}"
10
- end
5
+ class Where < ConditionClause
6
+ KEYWORD = "where"
11
7
  end
12
8
  end
13
9
  end