dapper-sqls 0.9.7__py3-none-any.whl → 1.2.0__py3-none-any.whl

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.
Files changed (37) hide show
  1. dapper_sqls/__init__.py +4 -2
  2. dapper_sqls/_types.py +25 -2
  3. dapper_sqls/async_dapper/async_dapper.py +1 -1
  4. dapper_sqls/async_dapper/async_executors.py +128 -53
  5. dapper_sqls/builders/model/model.py +421 -36
  6. dapper_sqls/builders/model/utils.py +337 -45
  7. dapper_sqls/builders/query.py +165 -44
  8. dapper_sqls/builders/stored.py +16 -10
  9. dapper_sqls/builders/stp.py +6 -2
  10. dapper_sqls/config.py +41 -32
  11. dapper_sqls/dapper/dapper.py +1 -1
  12. dapper_sqls/dapper/executors.py +131 -56
  13. dapper_sqls/decorators.py +5 -3
  14. dapper_sqls/http/__init__.py +4 -0
  15. dapper_sqls/http/aiohttp.py +155 -0
  16. dapper_sqls/http/decorators.py +123 -0
  17. dapper_sqls/http/models.py +58 -0
  18. dapper_sqls/http/request.py +140 -0
  19. dapper_sqls/models/__init__.py +3 -5
  20. dapper_sqls/models/base.py +246 -20
  21. dapper_sqls/models/connection.py +2 -2
  22. dapper_sqls/models/query_field.py +214 -0
  23. dapper_sqls/models/result.py +315 -45
  24. dapper_sqls/sqlite/__init__.py +5 -1
  25. dapper_sqls/sqlite/async_local_database.py +168 -0
  26. dapper_sqls/sqlite/decorators.py +69 -0
  27. dapper_sqls/sqlite/installer.py +97 -0
  28. dapper_sqls/sqlite/local_database.py +67 -185
  29. dapper_sqls/sqlite/models.py +51 -1
  30. dapper_sqls/sqlite/utils.py +9 -0
  31. dapper_sqls/utils.py +18 -6
  32. dapper_sqls-1.2.0.dist-info/METADATA +41 -0
  33. dapper_sqls-1.2.0.dist-info/RECORD +40 -0
  34. {dapper_sqls-0.9.7.dist-info → dapper_sqls-1.2.0.dist-info}/WHEEL +1 -1
  35. dapper_sqls-0.9.7.dist-info/METADATA +0 -19
  36. dapper_sqls-0.9.7.dist-info/RECORD +0 -30
  37. {dapper_sqls-0.9.7.dist-info → dapper_sqls-1.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,214 @@
1
+ from typing import Union, List, Literal, Any, Optional
2
+ from pydantic import BaseModel, Field, create_model
3
+ from datetime import datetime, date
4
+ from abc import ABC, abstractmethod
5
+
6
+ class QueryFieldBase(BaseModel, ABC):
7
+
8
+ class Config:
9
+ extra = "forbid"
10
+
11
+ prefix: Optional[str] = Field(
12
+ default=...,
13
+ description="Optional prefix to be prepended to the SQL condition (e.g., for parentheses or NOT)"
14
+ )
15
+ suffix: Optional[str] = Field(
16
+ default=...,
17
+ description="Optional suffix to be appended to the SQL condition (e.g., for closing parentheses)"
18
+ )
19
+
20
+ def quote(self, val):
21
+ if isinstance(val, str):
22
+ val = val.replace("'", "''")
23
+ return f"'{val}'"
24
+ elif isinstance(val, bool):
25
+ return '1' if val else '0'
26
+ elif isinstance(val, datetime):
27
+ return f"'{val.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}'"
28
+ elif isinstance(val, date):
29
+ return f"'{val.strftime('%Y-%m-%d')}'"
30
+ return str(val)
31
+
32
+ def format_sql(self, field_name: str, value_expr: str, operator: str) -> str:
33
+ prefix = self.prefix if isinstance(self.prefix, str) else ""
34
+ suffix = self.suffix if isinstance(self.suffix, str) else ""
35
+ return f"{prefix}{field_name} {operator} {value_expr}{suffix}"
36
+
37
+ @abstractmethod
38
+ def to_sql(self, field_name: str):
39
+ ...
40
+
41
+
42
+ class StringQueryField(QueryFieldBase):
43
+ value: Union[str, List[str]] = Field(
44
+ default=...,
45
+ description="The value or list of values to compare against the string column"
46
+ )
47
+ operator: Literal['=', '!=', 'LIKE', 'IN', 'NOT IN'] = Field(
48
+ default=...,
49
+ description="SQL operator used for comparison"
50
+ )
51
+
52
+ case_insensitive: bool = Field(
53
+ default=...,
54
+ description="Whether to apply case-insensitive matching (uses UPPER() on field and value)"
55
+ )
56
+
57
+ def apply_like_pattern(self, v: str) -> str:
58
+ if self.operator == 'LIKE':
59
+ return f"%{v}%"
60
+ return v
61
+
62
+ def to_sql(self, field_name: str) -> str:
63
+ field_expr = f"UPPER({field_name})" if self.case_insensitive else field_name
64
+
65
+ if isinstance(self.value, list):
66
+ values = [self.apply_like_pattern(v) for v in self.value]
67
+ values = [v.upper() if self.case_insensitive else v for v in values]
68
+ value_expr = "(" + ", ".join(self.quote(v) for v in values) + ")"
69
+ else:
70
+ val = self.apply_like_pattern(self.value)
71
+ val = val.upper() if self.case_insensitive else val
72
+ value_expr = self.quote(val)
73
+
74
+ return self.format_sql(field_expr, value_expr, self.operator)
75
+
76
+ class NumericQueryField(QueryFieldBase):
77
+ value: Union[int, float, List[Union[int, float]]] = Field(
78
+ default=...,
79
+ description="The numeric value or list of values to compare against the column"
80
+ )
81
+ operator: Literal['=', '!=', '>', '<', '>=', '<=', 'IN', 'NOT IN'] = Field(
82
+ default=...,
83
+ description="SQL operator used for numeric comparison"
84
+ )
85
+
86
+ def to_sql(self, field_name: str) -> str:
87
+ if isinstance(self.value, list):
88
+ value_expr = "(" + ", ".join(str(v) for v in self.value) + ")"
89
+ else:
90
+ value_expr = str(self.value)
91
+
92
+ return self.format_sql(field_name, value_expr, self.operator)
93
+
94
+ class BoolQueryField(QueryFieldBase):
95
+ value: bool = Field(
96
+ default=...,
97
+ description="Boolean value to compare against the column"
98
+ )
99
+ operator: Literal['=', '!='] = Field(
100
+ default=...,
101
+ description="SQL operator used for boolean comparison"
102
+ )
103
+
104
+ def to_sql(self, field_name: str) -> str:
105
+ value_expr = '1' if self.value else '0'
106
+ return self.format_sql(field_name, value_expr, self.operator)
107
+
108
+
109
+ class DateQueryField(QueryFieldBase):
110
+ value:Union[str, datetime, date] = Field(
111
+ default=...,
112
+ description="Date or datetime value to compare (can also be a string in ISO format)"
113
+ )
114
+ operator: Literal['=', '!=', '>', '<', '>=', '<='] = Field(
115
+ default=...,
116
+ description="SQL operator used for date/time comparison"
117
+ )
118
+
119
+ def to_sql(self, field_name: str) -> str:
120
+ if isinstance(self.value, str):
121
+ value_expr = f"'{self.value}'"
122
+ else:
123
+ value_expr = self.quote(self.value)
124
+ return self.format_sql(field_name, value_expr, self.operator)
125
+
126
+
127
+ class BytesQueryField(QueryFieldBase):
128
+ value: Union[bytes, List[bytes]] = Field(
129
+ default=...,
130
+ description="The bytes value or list of byte values to compare against the column"
131
+ )
132
+ operator: Literal['=', '!=', 'IN', 'NOT IN'] = Field(
133
+ default=...,
134
+ description="SQL operator used for byte comparison"
135
+ )
136
+
137
+ def to_sql(self, field_name: str) -> str:
138
+ def format_byte(b: bytes) -> str:
139
+ return "0x" + b.hex() # SQL Server format
140
+
141
+ if isinstance(self.value, list):
142
+ value_expr = "(" + ", ".join(format_byte(v) for v in self.value) + ")"
143
+ else:
144
+ value_expr = format_byte(self.value)
145
+
146
+ return self.format_sql(field_name, value_expr, self.operator)
147
+
148
+ class BaseJoinConditionField(BaseModel):
149
+ class Config:
150
+ extra = "forbid"
151
+
152
+ join_table_column: str = Field(
153
+ ...,
154
+ description="Join table column"
155
+ )
156
+ operator: Literal['=', '!=', '>', '<', '>=', '<=', 'IN', 'NOT IN', 'LIKE'] = Field(
157
+ default=...,
158
+ description="SQL operator used for join condition"
159
+ )
160
+
161
+ def to_sql(self, alias_table : str, field_name: str) -> str:
162
+ right = f"{alias_table}.{self.join_table_column}"
163
+ return f"{field_name} {self.operator} {right}"
164
+
165
+ @classmethod
166
+ def with_join_table_column_type(cls, join_table_column_type: Any):
167
+ new_model = create_model(
168
+ cls.__name__,
169
+ __base__=cls,
170
+ join_table_column=(
171
+ join_table_column_type,
172
+ Field(
173
+ ...,
174
+ description=cls.model_fields['join_table_column'].description
175
+ )
176
+ ),
177
+ )
178
+ return new_model
179
+
180
+ class JoinNumericCondition(BaseJoinConditionField):
181
+ operator: Literal['=', '!=', '>', '<', '>=', '<=', 'IN', 'NOT IN'] = Field(
182
+ default=...,
183
+ description="SQL operator used for numeric comparison"
184
+ )
185
+
186
+ class JoinNumericCondition(BaseJoinConditionField):
187
+ operator: Literal['=', '!=', '>', '<', '>=', '<=', 'IN', 'NOT IN'] = Field(
188
+ default=...,
189
+ description="SQL operator used for numeric comparison in a join condition"
190
+ )
191
+
192
+ class JoinStringCondition(BaseJoinConditionField):
193
+ operator: Literal['=', '!=', 'LIKE', 'IN', 'NOT IN'] = Field(
194
+ default=...,
195
+ description="SQL operator used for string comparison in a join condition"
196
+ )
197
+
198
+ class JoinBooleanCondition(BaseJoinConditionField):
199
+ operator: Literal['=', '!='] = Field(
200
+ default=...,
201
+ description="SQL operator used for boolean comparison in a join condition"
202
+ )
203
+
204
+ class JoinDateCondition(BaseJoinConditionField):
205
+ operator: Literal['=', '!=', '>', '<', '>=', '<='] = Field(
206
+ default=...,
207
+ description="SQL operator used for date/time comparison in a join condition"
208
+ )
209
+
210
+ class JoinBytesCondition(BaseJoinConditionField):
211
+ operator: Literal['=', '!=', 'IN', 'NOT IN'] = Field(
212
+ default=...,
213
+ description="SQL operator used for byte comparison in a join condition"
214
+ )
@@ -1,27 +1,168 @@
1
- # -*- coding: latin -*-
1
+ # coding: utf-8
2
+ from typing import Generic, Any
3
+ from .._types import SqlErrorType, SQL_ERROR_HTTP_CODES, T
4
+ from .base import SensitiveFields
5
+ import json
6
+ from collections import defaultdict
2
7
 
3
8
  def result_dict(cursor, result):
4
- return dict(
5
- zip(
6
- [column[0] for column in cursor.description],
7
- result
8
- )
9
- )
9
+ return dict(
10
+ zip(
11
+ [column[0] for column in cursor.description],
12
+ result
13
+ )
14
+ )
10
15
 
11
- class Result(object):
16
+ def classify_error(message: str) -> SqlErrorType:
17
+ msg = message.lower()
18
+
19
+ if "unique key constraint" in msg or "duplicate key" in msg:
20
+ return SqlErrorType.UNIQUE_VIOLATION
21
+ if "foreign key constraint" in msg:
22
+ return SqlErrorType.FOREIGN_KEY_VIOLATION
23
+ if "check constraint" in msg:
24
+ return SqlErrorType.CHECK_CONSTRAINT_VIOLATION
25
+ if "permission denied" in msg or "permission violation" in msg:
26
+ return SqlErrorType.PERMISSION_DENIED
27
+ if "syntax error" in msg:
28
+ return SqlErrorType.SYNTAX_ERROR
29
+ if "timeout" in msg:
30
+ return SqlErrorType.TIMEOUT
31
+ if any(kw in msg for kw in [
32
+ "could not connect",
33
+ "connection failed",
34
+ "server not found",
35
+ "network-related",
36
+ "login failed",
37
+ "connection timeout",
38
+ "transport-level error",
39
+ "communication link failure"
40
+ ]):
41
+ return SqlErrorType.CONNECTION_ERROR
12
42
 
13
- class Fetchone(object):
14
- def __init__(self, cursor, result, status_code = 200, message : str = ""):
43
+ return SqlErrorType.UNKNOWN
44
+
45
+ class Error(object):
46
+ def __init__(self, exception: Exception = None):
47
+ self.message = str(exception) if isinstance(exception, Exception) else ""
48
+ self.type = classify_error(self.message)
49
+
50
+ class BaseResult(object):
51
+ def __init__(self, query : str | tuple):
52
+ if isinstance(query, tuple):
53
+ q_str, *params = query
54
+ stored_procedure = {
55
+ "query": q_str,
56
+ "params": [list(p) if isinstance(p, tuple) else p for p in params]
57
+ }
58
+ self._query = json.dumps(stored_procedure)
59
+ else:
60
+ self._query = query
61
+
62
+ @property
63
+ def query(self):
64
+ return self._query
65
+
66
+ class Result(object):
67
+
68
+ class Count(BaseResult):
69
+ def __init__(self, query : str | tuple, result : int | str, status_code : int, error: Error):
70
+ super().__init__(query)
71
+ self._count = result
15
72
  self._status_code = status_code
16
- self._message = message
17
- if result:
73
+ self._success = bool(status_code == 200)
74
+ self._error = error
75
+
76
+ def model_dump(self):
77
+ if self.success:
78
+ return {'status_code': self.status_code, 'count': self.count}
79
+ else:
80
+ return {'status_code': self.status_code, 'message': self.error.message}
81
+
82
+ @property
83
+ def count(self):
84
+ return self._count
85
+
86
+ @property
87
+ def status_code(self):
88
+ return self._status_code
89
+
90
+ @property
91
+ def success(self):
92
+ return self._success
93
+
94
+ @property
95
+ def error(self):
96
+ return self._error
97
+
98
+ class Fetchone(BaseResult):
99
+ def __init__(self, query : str | tuple, cursor, result, exception: Exception = None):
100
+ super().__init__(query)
101
+ self._error = Error(exception)
102
+ self._list = []
103
+ self._dict : dict[str, Any] = {}
104
+ if cursor != None:
105
+ self._status_code = 200
18
106
  self._success = True
19
- self._list = result
20
- self._dict = dict(zip([column[0] for column in cursor.description], result))
107
+ if result:
108
+ sensitive_fields = SensitiveFields.get()
109
+ columns = [column[0] for column in cursor.description]
110
+ raw_dict = dict(zip(columns, result))
111
+ self._dict = {
112
+ k: v for k, v in raw_dict.items()
113
+ if k not in sensitive_fields
114
+ }
115
+ self._list = result
21
116
  else:
117
+ self._status_code = SQL_ERROR_HTTP_CODES.get(self._error.type, 500)
22
118
  self._success = False
23
- self._list = []
24
- self._dict = {}
119
+
120
+ def _organize_joined_tables(self, joins: list):
121
+ alias_to_table_name = {
122
+ join.model.TABLE_ALIAS: join.model.__class__.__name__ for join in joins
123
+ }
124
+
125
+ if not self._dict:
126
+ return
127
+
128
+ alias_data = defaultdict(dict)
129
+ keys_to_remove = []
130
+
131
+ for key, value in self._dict.items():
132
+ for alias_table, table_name in alias_to_table_name.items():
133
+ if alias_table in key:
134
+ column_name = key.replace(alias_table, '')
135
+ alias_data[table_name][column_name] = value
136
+ keys_to_remove.append(key)
137
+ break
138
+
139
+ for key in keys_to_remove:
140
+ self._dict.pop(key)
141
+
142
+ if alias_data:
143
+ self._dict['joined_tables'] = dict(alias_data)
144
+
145
+ if self._list:
146
+ columns = [col for col in self._dict.keys() if col]
147
+ self._list = [self._dict[col] for col in columns]
148
+
149
+ def model_dump(self, *, include: set[str] = None):
150
+ if not self.success:
151
+ return {
152
+ 'status_code': self.status_code,
153
+ 'message': self.error.message
154
+ }
155
+
156
+ result_dict = self._dict.copy()
157
+
158
+ if include is not None:
159
+ include = set(include)
160
+ result_dict = {k: v for k, v in result_dict.items() if k in include or k == 'joined_tables'}
161
+
162
+ return {
163
+ 'status_code': self.status_code,
164
+ 'data': result_dict
165
+ }
25
166
 
26
167
  @property
27
168
  def status_code(self):
@@ -40,24 +181,110 @@ class Result(object):
40
181
  return self._success
41
182
 
42
183
  @property
43
- def message(self):
44
- return self._message
184
+ def error(self):
185
+ return self._error
45
186
 
187
+ class FetchoneModel(Generic[T]):
188
+ def __init__(self, model_instance: T, fetchone_result: 'Result.Fetchone'):
189
+ self._model = model_instance
190
+ self._fetchone = fetchone_result
46
191
 
47
- class Fetchall:
192
+ @property
193
+ def query(self):
194
+ return self._fetchone.query
48
195
 
49
- def __init__(self, cursor, result, status_code = 200, message : str = ""):
50
- self._status_code = status_code
51
- self._message = message
52
- if result:
196
+ @property
197
+ def model(self) -> T:
198
+ return self._model
199
+
200
+ @property
201
+ def success(self):
202
+ return self._fetchone.success
203
+
204
+ @property
205
+ def dict(self):
206
+ return self._fetchone.dict
207
+
208
+ @property
209
+ def list(self):
210
+ return self._fetchone.list
211
+
212
+ @property
213
+ def status_code(self):
214
+ return self._fetchone.status_code
215
+
216
+ @property
217
+ def error(self):
218
+ return self._fetchone.error
219
+
220
+ def model_dump(self, *, include: set[str] = None):
221
+ return self._fetchone.model_dump(include=include)
222
+
223
+
224
+ class Fetchall(BaseResult):
225
+
226
+ def __init__(self, query : str | tuple, cursor, result, exception: Exception = None):
227
+ super().__init__(query)
228
+ self._error = Error(exception)
229
+ self._list_dict : list[dict[str, Any]] = []
230
+ if cursor != None:
231
+ self._status_code = 200
53
232
  self._success = True
54
- self._list_dict = []
55
- for r in result:
56
- self._list_dict.append(dict(zip([column[0] for column in cursor.description], r)))
233
+ if result:
234
+ sensitive_fields = SensitiveFields.get()
235
+ columns = [column[0] for column in cursor.description]
236
+ for r in result:
237
+ raw_dict = dict(zip(columns, r))
238
+ clean_dict = {
239
+ k: v for k, v in raw_dict.items()
240
+ if k not in sensitive_fields
241
+ }
242
+ self._list_dict.append(clean_dict)
57
243
  else:
244
+ self._status_code = SQL_ERROR_HTTP_CODES.get(self._error.type, 500)
58
245
  self._success = False
59
- self._list_dict = []
60
246
 
247
+ def _organize_joined_tables(self, joins: list):
248
+ alias_to_table_name = {
249
+ join.model.TABLE_ALIAS: join.model.__class__.__name__ for join in joins
250
+ }
251
+
252
+ for item in self._list_dict:
253
+ alias_data = defaultdict(dict)
254
+ keys_to_remove = []
255
+
256
+ for key, value in item.items():
257
+ for alias_table, table_name in alias_to_table_name.items():
258
+ if alias_table in key:
259
+ column_name = key.replace(alias_table, '')
260
+ alias_data[table_name][column_name] = value
261
+ keys_to_remove.append(key)
262
+ break
263
+
264
+ for key in keys_to_remove:
265
+ del item[key]
266
+
267
+ if alias_data:
268
+ item['joined_tables'] = dict(alias_data)
269
+
270
+ def model_dump(self, *, include: set[str] = None):
271
+ if not self.success:
272
+ return {
273
+ 'status_code': self.status_code,
274
+ 'message': self.error.message
275
+ }
276
+
277
+ data = self._list_dict
278
+
279
+ if include is not None:
280
+ include = set(include)
281
+ data = [{k: v for k, v in d.items() if k in include or k == 'joined_tables'} for d in data]
282
+
283
+ return {
284
+ 'status_code': self.status_code,
285
+ 'data': data
286
+ }
287
+
61
288
  @property
62
289
  def status_code(self):
63
290
  return self._status_code
@@ -66,24 +293,60 @@ class Result(object):
66
293
  def list_dict(self):
67
294
  return self._list_dict
68
295
 
69
- @property
70
- def dict(self):
71
- return self._dict
72
-
73
296
  @property
74
297
  def success(self):
75
298
  return self._success
76
299
 
77
300
  @property
78
- def message(self):
79
- return self._message
301
+ def error(self):
302
+ return self._error
303
+
304
+ class FetchallModel(Generic[T]):
305
+ def __init__(self, model_list: list[T], fetchall_result: 'Result.Fetchall'):
306
+ self._models = model_list
307
+ self._fetchall = fetchall_result
308
+
309
+ @property
310
+ def query(self):
311
+ return self._fetchall.query
312
+
313
+ @property
314
+ def models(self) -> list[T]:
315
+ return self._models
316
+
317
+ @property
318
+ def success(self):
319
+ return self._fetchall.success
320
+
321
+ @property
322
+ def list_dict(self):
323
+ return self._fetchall.list_dict
324
+
325
+ @property
326
+ def status_code(self):
327
+ return self._fetchall.status_code
328
+
329
+ @property
330
+ def error(self):
331
+ return self._fetchall.error
80
332
 
81
- class Insert:
82
- def __init__(self, result : int | str, status_code = 200, message : str = ""):
333
+ def model_dump(self, *, include: set[str] = None):
334
+ include = set(include)
335
+ return self._fetchall.model_dump(include=include)
336
+
337
+ class Insert(BaseResult):
338
+ def __init__(self, query : str | tuple, result : int | str, status_code : int, error: Error):
339
+ super().__init__(query)
83
340
  self._id = result
84
341
  self._status_code = status_code
85
- self._success = bool(result)
86
- self._message = message
342
+ self._success = bool(status_code == 200)
343
+ self._error = error
344
+
345
+ def model_dump(self):
346
+ if self.success:
347
+ return {'status_code': self.status_code, 'id': self.id}
348
+ else:
349
+ return {'status_code': self.status_code, 'message': self.error.message}
87
350
 
88
351
  @property
89
352
  def id(self):
@@ -98,15 +361,22 @@ class Result(object):
98
361
  return self._success
99
362
 
100
363
  @property
101
- def message(self):
102
- return self._message
364
+ def error(self):
365
+ return self._error
103
366
 
104
- class Send:
105
- def __init__(self, result : bool, status_code = 200, message : str = ""):
106
- self._status_code = status_code
367
+ class Send(BaseResult):
368
+ def __init__(self, query : str | tuple, result : bool, exception: Exception = None):
369
+ super().__init__(query)
370
+ self._error = Error(exception)
371
+ self._status_code = 200 if result else SQL_ERROR_HTTP_CODES.get(self._error.type, 500)
107
372
  self._success = result
108
- self._message = message
109
373
 
374
+ def model_dump(self):
375
+ if self.success:
376
+ return {'status_code': self.status_code}
377
+ else:
378
+ return {'status_code': self.status_code, 'message': self.error.message}
379
+
110
380
  @property
111
381
  def status_code(self):
112
382
  return self._status_code
@@ -116,8 +386,8 @@ class Result(object):
116
386
  return self._success
117
387
 
118
388
  @property
119
- def message(self):
120
- return self._message
389
+ def error(self):
390
+ return self._error
121
391
 
122
392
 
123
393
 
@@ -1,4 +1,8 @@
1
- from .local_database import LocalDatabase
1
+ from .local_database import BaseLocalDatabase
2
+ from .async_local_database import BaseAsyncLocalDatabase
3
+ from .installer import DataBaseInstall
4
+ from .models import BaseTables
5
+ from .decorators import safe_sqlite_operation, SqliteErrorType
2
6
 
3
7
 
4
8