lysync 1.0.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.
lysync/__init__.py ADDED
File without changes
lysync/admin.py ADDED
@@ -0,0 +1,3 @@
1
+ from django.contrib import admin
2
+
3
+ # Register your models here.
lysync/apps.py ADDED
@@ -0,0 +1,25 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class LysyncConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "lysync"
7
+
8
+ def ready(self):
9
+ # 确保内置自定义函数(process / collect)完成注册
10
+ import lysync.funcs # noqa: F401
11
+ # 收集 Celery 任务定义
12
+ import lysync.tasks # noqa: F401
13
+
14
+ # 注册数据源信号(post_save / 批量更新连接检查)
15
+ import lysync.signals # noqa: F401 (触发 post_save 注册)
16
+
17
+
18
+ # 初始化周期性任务(导出 / 中断校验)
19
+ try:
20
+ from lysync.tasks import init_periodic_tasks
21
+
22
+ init_periodic_tasks()
23
+ except Exception:
24
+ # 在缺少 django_celery_beat 或还未迁移的环境中静默忽略
25
+ pass
@@ -0,0 +1,10 @@
1
+ """导出功能包:采集 -> 处理 -> 写入目标库。
2
+
3
+ 子模块各自独立导入即可(避免在本 __init__ 中提前引入需要 Django 的子模块):
4
+
5
+ * ``from lysync.export.collector import collect``
6
+ * ``from lysync.export.processor import run_field_process_list, run_dataset_process_list``
7
+ * ``from lysync.export.writer import write_rows_to_target``
8
+ * ``from lysync.export.engine import exec_sync_export_task_item, exec_sync_export_check_item``
9
+ * ``from lysync.export.utils import fetch_data_from_temp_db, render_sql_template, ...``
10
+ """
@@ -0,0 +1,72 @@
1
+ """数据采集层。
2
+
3
+ 根据 :class:`~lysync.models.SyncDataSource` 的类型,从外部数据库(SQL)或
4
+ 自定义采集函数(``custom_func``)获取原始数据,返回 ``list[dict]``。
5
+
6
+ 数据库采集改为 **按天(前闭后开)** 同步:``SyncExportTask.sql`` 即 jinja2 模板,
7
+ 由 :func:`~lysync.export.utils.render_sql_template` 注入 ``start_time`` /
8
+ ``end_time``(均为 ``datetime``,区间为 ``[start_time, end_time)``)渲染成最终 SQL。
9
+ 区间过滤逻辑由模板内 SQL 自己实现(不再使用 sqlglot 追加分页参数)。
10
+
11
+ 返回的字典键名应与目标模型字段(``SyncModelField.field_name``)保持一致。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from lysync.funcs.registry import get_collect_func
16
+ from lysync.models import SyncDataSource, SyncExportTask
17
+ from lysync.export.utils import fetch_data_from_temp_db, render_sql_template
18
+
19
+
20
+ def collect_from_db(data_source: SyncDataSource, sql: str, start_time, end_time):
21
+ """从数据库采集:用 jinja2 渲染 SQL 模板(注入 ``start_time`` / ``end_time``)。"""
22
+ exec_sql = render_sql_template(sql, start_time, end_time)
23
+ rows = fetch_data_from_temp_db(
24
+ data_source.db_type,
25
+ data_source.db_host,
26
+ data_source.db_port,
27
+ data_source.db_user,
28
+ data_source.db_password,
29
+ data_source.db_name,
30
+ data_source.db_options,
31
+ exec_sql,
32
+ )
33
+ return rows or [], exec_sql
34
+
35
+
36
+ def collect_custom(
37
+ data_source: SyncDataSource,
38
+ task: SyncExportTask = None,
39
+ start_time=None,
40
+ end_time=None,
41
+ **extra_kwargs,
42
+ ):
43
+ """调用自定义采集函数,并把 ``start_time`` / ``end_time`` 透传进去。
44
+
45
+ 自定义函数签名:``func(start_time=..., end_time=..., **kwargs) -> list[dict]``。
46
+ """
47
+ if not data_source.custom_func:
48
+ raise ValueError("数据源未配置 custom_func(自定义采集函数名称)")
49
+ func = get_collect_func(data_source.custom_func)
50
+
51
+ kwargs = {}
52
+ if isinstance(data_source.custom_func_kwargs, dict):
53
+ kwargs.update(data_source.custom_func_kwargs)
54
+ if task is not None and isinstance(task.custom_func_kwargs, dict):
55
+ kwargs.update(task.custom_func_kwargs)
56
+ kwargs.update(start_time=start_time, end_time=end_time)
57
+ kwargs.update(extra_kwargs)
58
+
59
+ rows = func(**kwargs)
60
+ if not isinstance(rows, list):
61
+ raise TypeError(f"自定义采集函数 {data_source.custom_func} 应返回 list[dict],实际返回 {type(rows)}")
62
+ return rows, None
63
+
64
+
65
+ def collect(task: SyncExportTask, start_time=None, end_time=None, **extra_kwargs):
66
+ """统一采集入口。返回 ``(rows, exec_sql)``。"""
67
+ data_source = task.data_source
68
+ if data_source.source_type == SyncDataSource.SOURCE_TYPE_CUSTOM:
69
+ return collect_custom(data_source, task, start_time=start_time, end_time=end_time, **extra_kwargs)
70
+ if not task.sql:
71
+ raise ValueError("数据库类型数据源必须配置 SQL")
72
+ return collect_from_db(data_source, task.sql, start_time, end_time)
@@ -0,0 +1,306 @@
1
+ """导出引擎(核心编排)。
2
+
3
+ 替代旧版“先导出 Excel 再二次处理”的流程:直接把源数据经 ``process_list`` 处理链
4
+ 加工后写入目标库(upsert)。
5
+
6
+ 与旧版的关键差异:
7
+ * **按天同步**:每次执行只同步 ``SyncExportTask.data_start_date`` 这一天的数据。
8
+ ``SyncExportTask.sql`` 是 jinja2 模板,渲染时注入 ``start_time``(当天 00:00:00)
9
+ 与 ``end_time``(次日 00:00:00),构成 **前闭后开** 区间 ``[start_time, end_time)``,
10
+ 区间过滤由模板内 SQL 自己实现(不再用 sqlglot 追加分页参数)。
11
+ * 执行完成后 ``data_start_date`` 推进到次日;若仍早于“今天”则 ``has_more=True``,
12
+ 由上层 Celery 触发下一天的同步。
13
+ * 数据变更检测按天进行:每次只处理当天的行,并通过 ``SyncDataFingerprint`` 比对指纹。
14
+
15
+ 处理顺序:``SyncModelField``(字段级) -> ``SyncModelType``(数据集级) ->
16
+ ``SyncExportTask``(数据集级)。
17
+
18
+ 对外主要函数:
19
+ * :func:`exec_sync_export_task_item` —— 执行一天的导出任务。
20
+ * :func:`exec_sync_export_check_item` —— 对已有导出记录做按天补录。
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import datetime
25
+ import uuid
26
+
27
+ from celery.exceptions import SoftTimeLimitExceeded
28
+ from django.conf import settings
29
+ from django.utils import timezone
30
+ from loguru import logger
31
+
32
+ from lysync.models import (
33
+ EXPORT_RECORD_STATUS_FAIL,
34
+ EXPORT_RECORD_STATUS_RUNNING,
35
+ EXPORT_RECORD_STATUS_SUCCESS,
36
+ SyncDataSource,
37
+ SyncExportCheckRecord,
38
+ SyncExportRecord,
39
+ SyncExportTask,
40
+ SyncModelField,
41
+ SyncModelType,
42
+ )
43
+ from lysync.export.collector import collect
44
+ from lysync.export.processor import run_dataset_process_list, run_field_process_list
45
+ from lysync.export.utils import fetch_data_from_temp_db, render_sql_template
46
+ from lysync.utils import local_today
47
+ from lysync.export.writer import write_rows_to_target
48
+
49
+
50
+ def _patch_logger(request_id=None):
51
+ try:
52
+ from util.middleware import get_request_id
53
+
54
+ return logger.patch(lambda record: record["extra"].update(**get_request_id()))
55
+ except Exception:
56
+ return logger
57
+
58
+
59
+ def _day_bounds(data_start_date):
60
+ """将日期转换为当天 00:00:00 与次日 00:00:00 的 datetime(naive,本地时区语义)。"""
61
+ start_time = datetime.datetime(data_start_date.year, data_start_date.month, data_start_date.day)
62
+ end_time = start_time + datetime.timedelta(days=1)
63
+ return start_time, end_time
64
+
65
+
66
+ def _last_unique_hash(processed_rows, unique_pairs, fields):
67
+ """取当天批次中“时间索引字段最大”的那条行的唯一标识。
68
+
69
+ 优先使用配置了 ``is_datetime_index=True`` 的字段作为时间维度;若没有该字段或
70
+ 批次为空,则回退到列表最后一条。
71
+ """
72
+ if not unique_pairs:
73
+ return None
74
+ dt_field = next((f.field_name for f in fields if getattr(f, "is_datetime_index", False)), None)
75
+ if not dt_field:
76
+ return unique_pairs[-1][0]
77
+
78
+ def _sortable(i):
79
+ v = processed_rows[i].get(dt_field)
80
+ if v is None:
81
+ return (1, 0)
82
+ if hasattr(v, "timestamp"):
83
+ return (0, v.timestamp())
84
+ try:
85
+ return (0, float(v))
86
+ except (TypeError, ValueError):
87
+ return (0, 0)
88
+
89
+ best = max(range(len(processed_rows)), key=_sortable)
90
+ return unique_pairs[best][0]
91
+
92
+
93
+ def _process_rows(task: SyncExportTask, raw_rows):
94
+ """按处理顺序执行字段级 / 数据集级 process_list。
95
+
96
+ 字段采集开关 ``is_import_column``:
97
+ * ``True``:从采集结果中保留其值,参与字段级处理并写入目标库。
98
+ * ``False``:**采集函数不应输出该字段的值**——其值由 ``SyncModelType`` 的
99
+ ``process_list``(数据集级)等逻辑负责计算 / 产出。因此在字段级处理前
100
+ 将其从采集结果中剔除,字段级处理也跳过它。
101
+ """
102
+ model_type = task.model_type
103
+ fields = list(SyncModelField.objects.filter(model_type=model_type).order_by("id"))
104
+ import_fields = [f for f in fields if f.is_import_column]
105
+ non_import_names = {f.field_name for f in fields if not f.is_import_column}
106
+
107
+ # 剔除非导入列的值(收集函数不应输出该字段的值)
108
+ if non_import_names:
109
+ raw_rows = [{k: v for k, v in row.items() if k not in non_import_names} for row in raw_rows]
110
+
111
+ # 1) 字段级:仅对导入列逐字段处理值
112
+ processed = []
113
+ for raw in raw_rows:
114
+ row = dict(raw) # 避免修改原始数据
115
+ for field in import_fields:
116
+ row[field.field_name] = run_field_process_list(
117
+ field.process_list, row.get(field.field_name), field.field_name
118
+ )
119
+ processed.append(row)
120
+
121
+ # 2) 数据集级:SyncModelType(此处可产出 is_import_column=False 的派生字段)
122
+ processed = run_dataset_process_list(model_type.process_list, processed)
123
+ # 3) 数据集级:SyncExportTask
124
+ processed = run_dataset_process_list(task.process_list, processed)
125
+ return processed
126
+
127
+
128
+ def exec_sync_export_task_item(task: SyncExportTask, request_id=None):
129
+ """执行一天的导出任务(采集 -> 处理 -> 写入目标库)。
130
+
131
+ 以 ``task.data_start_date`` 为当天,渲染 ``[start_time, end_time)`` 区间 SQL,
132
+ 同步完成后把 ``data_start_date`` 推进到次日。
133
+
134
+ Returns:
135
+ ``(success: bool, message: str, has_more: bool)``,``has_more`` 表示是否还有
136
+ 后续日期需要继续同步(``data_start_date`` 仍早于今天)。
137
+ """
138
+ request_id = request_id or uuid.uuid4().hex
139
+ log = _patch_logger(request_id)
140
+
141
+ task.last_export_time = timezone.now()
142
+ data_source = task.data_source
143
+ is_db = data_source.source_type != SyncDataSource.SOURCE_TYPE_CUSTOM
144
+
145
+ if not task.data_start_date:
146
+ msg = "请先配置 data_start_date(数据开始日期)后再执行同步"
147
+ log.warning(f"[{request_id}]{msg}: id={task.id}")
148
+ record = SyncExportRecord(
149
+ org_id=task.org_id, export_task=task, export_status=EXPORT_RECORD_STATUS_FAIL, message=msg
150
+ )
151
+ record.save()
152
+ return False, msg, False
153
+
154
+ start_time, end_time = _day_bounds(task.data_start_date)
155
+
156
+ record = SyncExportRecord()
157
+ record.org_id = task.org_id
158
+ record.export_task = task
159
+ record.data_date = task.data_start_date
160
+ record.export_status = EXPORT_RECORD_STATUS_RUNNING
161
+ try:
162
+ raw_rows, exec_sql = collect(task, start_time=start_time, end_time=end_time)
163
+ record.original_sql = task.sql or ""
164
+ record.exec_sql = exec_sql or ""
165
+ record.save()
166
+ task.save(update_fields=["last_export_time", "update_time"])
167
+
168
+ if not raw_rows:
169
+ log.info(f"[{request_id}]无新数据: id={task.id}, name={task.name}, date={task.data_start_date}")
170
+ record.export_status = EXPORT_RECORD_STATUS_SUCCESS
171
+ record.message = "无新数据"
172
+ record.save()
173
+ # 当天无数据也要推进到次日,避免死循环
174
+ task.data_start_date = end_time.date()
175
+ task.save(update_fields=["data_start_date", "last_export_time", "update_time"])
176
+ has_more = task.data_start_date < local_today()
177
+ return True, "无新数据", has_more
178
+
179
+ fields = list(SyncModelField.objects.filter(model_type=task.model_type).order_by("id"))
180
+ processed = _process_rows(task, raw_rows)
181
+ created, updated, unique_pairs = write_rows_to_target(task.model_type, processed, task, task.org_id)
182
+
183
+ record.export_status = EXPORT_RECORD_STATUS_SUCCESS
184
+ record.message = f"导入成功,新增 {created},更新 {updated}"
185
+ record.export_count = len(processed)
186
+ record.export_total_count = len(processed)
187
+ record.export_unique_hash_list = [h for h, _ in unique_pairs]
188
+ record.save()
189
+
190
+ # 推进日期与位点
191
+ last_hash = _last_unique_hash(processed, unique_pairs, fields)
192
+ task.last_export_unique_hash = last_hash
193
+ task.data_start_date = end_time.date()
194
+ task.last_export_time = timezone.now()
195
+ task.save(
196
+ update_fields=["last_export_unique_hash", "data_start_date", "last_export_time", "update_time"]
197
+ )
198
+
199
+ has_more = task.data_start_date < local_today()
200
+ log.info(
201
+ f"[{request_id}]导出成功: id={task.id}, name={task.name}, date={record.data_date}, "
202
+ f"新增{created}, 更新{updated}"
203
+ )
204
+ return True, record.message, has_more
205
+ except SoftTimeLimitExceeded:
206
+ log.exception(f"[{request_id}]task_id={task.id}. 任务超时")
207
+ record.export_status = EXPORT_RECORD_STATUS_FAIL
208
+ record.message = "任务超时"
209
+ record.save()
210
+ return False, "任务超时", False
211
+ except Exception as e:
212
+ import traceback
213
+
214
+ bt = str(traceback.format_exc())
215
+ msg = "执行查询超时" if "SoftTimeLimitExceeded" in bt else str(e)
216
+ log.exception(f"[{request_id}]task_id={task.id}. 导出数据失败: {e}")
217
+ try:
218
+ record.export_status = EXPORT_RECORD_STATUS_FAIL
219
+ record.message = msg
220
+ record.save()
221
+ except Exception:
222
+ pass
223
+ return False, msg, False
224
+
225
+
226
+ def exec_sync_export_check_item(export_record: SyncExportRecord, request_id=None):
227
+ """对已有导出记录做按天补录(重新同步该记录所属日期的数据并 upsert 变更行)。"""
228
+ request_id = request_id or uuid.uuid4().hex
229
+ log = _patch_logger(request_id)
230
+
231
+ export_task = export_record.export_task
232
+ data_source = export_task.data_source
233
+ is_db = data_source.source_type != SyncDataSource.SOURCE_TYPE_CUSTOM
234
+
235
+ check_record = SyncExportCheckRecord()
236
+ check_record.org_id = export_record.org_id
237
+ check_record.export_task = export_task
238
+ check_record.export_record = export_record
239
+ check_record.export_status = EXPORT_RECORD_STATUS_RUNNING
240
+ try:
241
+ if not export_record.data_date:
242
+ raise ValueError("导出记录缺少 data_date,无法按天补录")
243
+ start_time, end_time = _day_bounds(export_record.data_date)
244
+ if is_db:
245
+ exec_sql = render_sql_template(export_task.sql, start_time, end_time)
246
+ raw_rows = (
247
+ fetch_data_from_temp_db(
248
+ data_source.db_type,
249
+ data_source.db_host,
250
+ data_source.db_port,
251
+ data_source.db_user,
252
+ data_source.db_password,
253
+ data_source.db_name,
254
+ data_source.db_options,
255
+ exec_sql,
256
+ )
257
+ or []
258
+ )
259
+ check_record.original_sql = export_task.sql
260
+ check_record.exec_sql = exec_sql
261
+ else:
262
+ raw_rows, _ = collect(export_task, start_time=start_time, end_time=end_time)
263
+ check_record.original_sql = export_task.sql or ""
264
+ check_record.exec_sql = ""
265
+
266
+ check_record.save()
267
+ export_record.save(update_fields=["last_check_time", "update_time"])
268
+
269
+ if not raw_rows:
270
+ check_record.delete()
271
+ return True, None, False
272
+
273
+ processed = _process_rows(export_task, raw_rows)
274
+ created, updated, unique_pairs = write_rows_to_target(
275
+ export_task.model_type, processed, export_task, export_record.org_id
276
+ )
277
+
278
+ check_record.export_status = EXPORT_RECORD_STATUS_SUCCESS
279
+ check_record.message = "补录导入成功"
280
+ check_record.export_count = created + updated
281
+ check_record.export_unique_hash_list = [h for h, _ in unique_pairs]
282
+ check_record.save()
283
+
284
+ export_record.last_check_record_id = check_record.id
285
+ export_record.success_check_record_count = SyncExportCheckRecord.objects.filter(
286
+ export_record_id=export_record.id, export_status=EXPORT_RECORD_STATUS_SUCCESS
287
+ ).count()
288
+ export_record.save(
289
+ update_fields=["success_check_record_count", "last_check_record_id", "update_time"]
290
+ )
291
+ log.info(
292
+ f"[{request_id}]补录成功: id={export_record.id}, name={export_task.name}, 新增{created}, 更新{updated}"
293
+ )
294
+ return True, check_record.message, False
295
+ except SoftTimeLimitExceeded:
296
+ log.exception(f"[{request_id}]task_record_id={export_record.id}. 任务超时")
297
+ check_record.export_status = EXPORT_RECORD_STATUS_FAIL
298
+ check_record.message = "任务超时"
299
+ check_record.save()
300
+ return False, "任务超时", False
301
+ except Exception as e:
302
+ log.exception(f"[{request_id}]task_record_id={export_record.id}. 补录失败: {e}")
303
+ check_record.export_status = EXPORT_RECORD_STATUS_FAIL
304
+ check_record.message = str(e)
305
+ check_record.save()
306
+ return False, str(e), False
@@ -0,0 +1,110 @@
1
+ """统一的 Jinja2 环境(单例)。
2
+
3
+ 整个项目里所有 SQL 模板渲染共用同一个 :class:`jinja2.Environment`,便于:
4
+
5
+ * **扩展内部函数 / 数据**:通过 :func:`register_global` / :func:`register_filter`
6
+ 注册自定义全局函数与过滤器,模板内即可直接调用;渲染时也可通过
7
+ :func:`render_sql_template` 的关键字参数注入任意额外上下文。
8
+ * **复用编译缓存**:``env.from_string(sql)`` 比每次 ``Template(sql).render`` 更高效,
9
+ 且共享同一套全局/过滤器配置。
10
+ * **统一配置**:SQL 模板不需要 HTML 自动转义,这里统一关闭,避免把 ``< > &`` 等符号转义。
11
+
12
+ 内置可用资源(模板内):
13
+
14
+ * 全局函数:``now()`` / ``utcnow()`` / ``today()`` 返回当前时间/日期;
15
+ ``sqlstr(value)`` 返回带单引号转义的 SQL 字面量。
16
+ * 过滤器:``value | date(fmt)`` / ``value | strftime(fmt)`` 格式化日期;
17
+ ``value | sqlstr`` 等价于 ``sqlstr(value)``。
18
+
19
+ 扩展示例(在 app 启动时或任意可导入处调用)::
20
+
21
+ from lysync.export.jinja_env import register_global
22
+
23
+ def fiscal_year(dt):
24
+ return dt.year if dt.month >= 4 else dt.year - 1
25
+
26
+ register_global("fiscal_year", fiscal_year)
27
+
28
+ 之后模板即可使用 ``{{ start_time | fiscal_year }}``。
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import datetime
33
+
34
+ from jinja2 import Environment
35
+
36
+
37
+ # SQL 模板不需要 HTML 自动转义;关闭以避免把 < > & 等符号转义成实体
38
+ _AUTOESCAPE = False
39
+
40
+
41
+ def _sqlstr(value):
42
+ """返回带单引号转义的 SQL 字符串字面量(最小实现,仅转义单引号)。"""
43
+ if value is None:
44
+ return "NULL"
45
+ return "'" + str(value).replace("'", "''") + "''"
46
+
47
+
48
+ # 默认注入模板的内置全局函数
49
+ _DEFAULT_GLOBALS = {
50
+ "now": datetime.datetime.now,
51
+ "utcnow": datetime.datetime.utcnow,
52
+ "today": datetime.date.today,
53
+ "sqlstr": _sqlstr,
54
+ }
55
+
56
+ # 默认注入模板的内置过滤器
57
+ _DEFAULT_FILTERS = {
58
+ "date": lambda value, fmt="%Y-%m-%d": value.strftime(fmt) if isinstance(value, (datetime.datetime, datetime.date)) else (value or ""),
59
+ "strftime": lambda value, fmt="%Y-%m-%d": value.strftime(fmt) if isinstance(value, (datetime.datetime, datetime.date)) else (value or ""),
60
+ "sqlstr": _sqlstr,
61
+ }
62
+
63
+
64
+ _env = None
65
+
66
+
67
+ def get_jinja_env() -> Environment:
68
+ """返回全局共享的 Jinja2 Environment(懒加载单例)。
69
+
70
+ 首次调用时创建并注册内置全局函数 / 过滤器。后续可通过
71
+ :func:`register_global` / :func:`register_filter` 继续扩展。
72
+ """
73
+ global _env
74
+ if _env is None:
75
+ _env = Environment(autoescape=_AUTOESCAPE, trim_blocks=False, lstrip_blocks=False)
76
+ _env.globals.update(_DEFAULT_GLOBALS)
77
+ _env.filters.update(_DEFAULT_FILTERS)
78
+ return _env
79
+
80
+
81
+ def register_global(name: str, func):
82
+ """注册一个模板可用的全局函数(覆盖同名项),之后可在模板中直接调用。"""
83
+ get_jinja_env().globals[name] = func
84
+
85
+
86
+ def register_filter(name: str, func):
87
+ """注册一个模板可用的过滤器(覆盖同名项),之后可用 ``value | name`` 调用。"""
88
+ get_jinja_env().filters[name] = func
89
+
90
+
91
+ def render_sql_template(sql, start_time=None, end_time=None, **context):
92
+ """使用统一 Jinja2 环境渲染 SQL 模板。
93
+
94
+ Args:
95
+ sql: jinja2 模板字符串(可含 ``{{ start_time }}`` / ``{{ end_time }}`` 等)。
96
+ start_time / end_time: 默认注入的上下文变量(多为 datetime,构成前闭后开区间)。
97
+ **context: 任意额外的模板上下文,覆盖/补充默认注入的变量。
98
+
99
+ Returns:
100
+ 渲染后的 SQL 字符串。不存在 ``{{ }}`` 的普通 SQL 由原样返回,兼容旧写法。
101
+
102
+ 模板内还可使用内置全局函数(``now()`` / ``today()`` / ``sqlstr()`` 等)与
103
+ 过滤器(``|date`` / ``|strftime`` / ``|sqlstr``),并可通过
104
+ :func:`register_global` / :func:`register_filter` 扩展。
105
+ """
106
+ if not sql:
107
+ return sql
108
+ ctx = {"start_time": start_time, "end_time": end_time}
109
+ ctx.update(context)
110
+ return get_jinja_env().from_string(sql).render(**ctx)
@@ -0,0 +1,40 @@
1
+ """数据处理链(``process_list``)执行层。
2
+
3
+ 处理顺序约定为:
4
+ ``SyncModelField``(字段级) -> ``SyncModelType``(数据集级) -> ``SyncExportTask``(数据集级)。
5
+
6
+ * 字段级:对**单个字段的值**依次调用 ``process_list`` 中的每个函数,
7
+ 签名 ``func(value, **kwargs) -> value``。
8
+ * 数据集级:对**整批数据**(``list[dict]``)依次调用,签名 ``func(rows, **kwargs) -> rows``。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from lysync.funcs.registry import get_process_func
13
+
14
+
15
+ def run_field_process_list(process_list, value, field_name=None):
16
+ """对单个字段值执行 ``process_list``。"""
17
+ if not process_list:
18
+ return value
19
+ for item in process_list:
20
+ name = item.get("func")
21
+ if not name:
22
+ continue
23
+ kwargs = item.get("kwargs") or {}
24
+ func, _scope = get_process_func(name)
25
+ value = func(value, **kwargs)
26
+ return value
27
+
28
+
29
+ def run_dataset_process_list(process_list, rows):
30
+ """对整批数据执行 ``process_list``。"""
31
+ if not process_list:
32
+ return rows
33
+ for item in process_list:
34
+ name = item.get("func")
35
+ if not name:
36
+ continue
37
+ kwargs = item.get("kwargs") or {}
38
+ func, _scope = get_process_func(name)
39
+ rows = func(rows, **kwargs)
40
+ return rows