免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

Frappe Query Builder 全面解析:从 SQL 生成引擎到多数据库兼容实践

Frappe Query Builder 全面解析:从 SQL 生成引擎到多数据库兼容实践 Frappe Query Builder 全面解析从 SQL 生成引擎到多数据库兼容实践【免费下载链接】frappeLow code web framework for real world applications, in Python and Javascript项目地址: https://gitcode.com/GitHub_Trending/fr/frappe本篇技术指南以 Frappe 框架的查询构建模块Query Builder为核心系统讲解其基于 pypika 的 SQL 生成架构、四种方言MariaDB / PostgreSQL / SQLite的适配机制、Engine引擎对多种过滤器与字段记法的统一处理以及它是如何作为frappe.db.get_values、frappe.get_all、frappe.get_list等数据库 API 的底层动力。读完本文你将掌握 Query Builder 的三种使用姿势、底层参数化原理、跨数据库函数映射以及它在权限、安全与性能层面的关键设计取舍。一、Query Builder 是什么Frappe 的 Query Builder 位于 frappe/query_builder/ 目录是一层构建在 pypika 之上的 SQL 查询构造器。它的目标非常具体让查询一张表这件事有多种等价的表达方式并把它们统一收敛到同一条生成链路中。文档开篇用一条最简单的 SQL 定义了模块的北极星目标select name from tabUser围绕着这条 SQL文档给出了三种抵达方式它们从直接写 SQL到完全面向对象层层递进也正是理解整个模块的切入点。方式一直接 SQL文档原话Boring / Unsafe / inconsistentfrappe.db.sql(select name from tabUser)这是最原始的方式字符串拼接、无法参数化容易引入 SQL 注入风险且不同数据库方言下需要手写不同语法既不安全也不一致。方式二通过 Query Builder 对象生成 SQLfrom frappe.query_builder import Field frappe.qb.from_(User).select(Field(name))这里frappe.qb是当前站点按数据库类型选定的构建器实例详见第二节from_接收 DocType 名称字符串内部会自动做表名转换与合法性校验最终生成与方式一完全相同的 SQL。方式三通过数据库 API内部即执行方式二frappe.db.get_values(User, fieldnamename, filters{})这是绝大多数应用代码日常使用的形式。文档明确指出This module is used to support the 3rd way of query generation in frappe. The database module is completely powered by this query module.也就是说frappe.db.get_values内部实际调用的正是方式二的 Query Builder 对象。这个桥接层是为了在旧的 raw SQL 生成方式与新增的 Query Builder 支持之间架起一座桥从而让get_values等旧 API 在不改变外部签名的情况下底层切换到新的查询引擎。可以从 frappe/database/database.py 的导入中看到这种依赖关系的证据——database.py直接from frappe.query_builder import Case、from frappe.query_builder.functions import Count并在内部使用Engine与frappe.qb构建查询。此外frappe/tests/test_db_query.py 中有大量用例同时覆盖frappe.qb.get_query(...)与frappe.db.get_values(...)的行为一致性例如用filters{restrict_to_domain: None}、filters{1field: [like, 1%]}等记法验证两者等价。二、Builder方言感知的构建器家族文档在 Related Files 一节列出了模块的核心文件其中builder的职责是Database specific classes are declared which are then selected during init to give either postgres or mariadb dialects.也就是说构建器的选择发生在初始化阶段根据当前站点配置的数据库类型从三个方言类中选出一个挂载为frappe.qb。这一逻辑实现在 frappe/query_builder/utils.pyclass db_type_is(Enum): MARIADB mariadb POSTGRES postgres SQLITE sqlite DB_TYPE_MAP { db_type_is.MARIADB: MariaDB, db_type_is.POSTGRES: Postgres, db_type_is.SQLITE: SQLite, } def get_query_builder(type_of_db: str) - Postgres | MariaDB | SQLite: return DB_TYPE_MAP[db_type_is(type_of_db)]而三个方言类定义在 frappe/query_builder/builder.pyclass MariaDB(Base, MySQLQuery)使用 pypika 的MySQLQuery作为基类构建器为RecursiveMySQLQueryBuilderclass Postgres(Base, PostgreSQLQuery)使用PostgreSQLQuery并针对information_schema做了字段与表名的翻译映射table_name → relname、table_rows → n_tup_ins、tables → pg_stat_all_tablesclass SQLite(Base, SQLLiteQuery)使用SQLLiteQuerySQL 值包装器换成SQLiteParameterizedValueWrapper。2.1 公共基类 BaseBase是所有方言的公共父类它做了几件重要的事聚合 pypika 的 termsBase.terms _flatten(terms)把 pypika 的terms模块打平成字典Base.desc / Base.asc暴露排序方向Base.Schema / Base.Table暴露表与模式对象DocType 名称校验DocType(table_name)先通过TABLE_NAME_PATTERN re.compile(r^[\w -]*$)校验名称合法性该正则特意放宽容许__Auth这类带下划线的内部表名再把 DocType 名经frappe.utils.get_table_name转成实际表名即User→tabUser最后构造 pypikaTablefrom_/into/update的字符串重载这三个类方法都接受 DocType 字符串自动走DocType()转换从而让frappe.qb.from_(User)这样的调用可以直接写表名字符串。2.2 递归 CTE 支持builder.py 中还定义了RecursiveCTEMixin为三种方言的查询构建器注入了WITH RECURSIVE支持class RecursiveCTEMixin: Adds WITH RECURSIVE support to a pypika query builder... def __init__(self, *args, recursive: bool False, **kwargs): super().__init__(*args, **kwargs) self._recursive_cte recursive def _with_sql(self, **kwargs) - str: keyword WITH RECURSIVE if getattr(self, _recursive_cte, False) else WITH ...使用方式是在frappe.qb.with_(subquery, name, recursiveTrue)传入recursiveTrue并在递归体内通过pypika.Table(name)引用自身。该能力在 MariaDB 10.2、PostgreSQL 和 SQLite 上都会渲染为WITH RECURSIVE不传recursive时生成的 SQL 与普通WITH逐字节一致。2.3 初始化时的 Monkey Patch在 frappe/query_builder/init.py 中模块加载时对 pypika 做了一批关键替换pypika.terms.ValueWrapper ParameterizedValueWrapper pypika.terms.Function.get_sql ParameterizedFunction.get_sql pypika.terms.Function ParameterizedFunction # Overrides the field() method and replaces it with a PseudoColumn field for consistency pypika.queries.Selectable.__getattr__ ignore_copy(lambda table, x: Field(x, tabletable)) pypika.queries.Selectable.__getitem__ ignore_copy(lambda table, x: Field(x, tabletable)) pypika.queries.Selectable.field pypika.terms.PseudoColumn(field) # run monkey patches patch_all()ValueWrapper被替换为ParameterizedValueWrapper这是参数化能力的根基详见第四节Function.get_sql被替换为ParameterizedFunction.get_sql确保函数表达式也能把参数收进param_wrapperSelectable.__getattr__ / __getitem__被重写让table[field]与table.field都能返回对应的Field对象——这是frappe.qb.DocType(User).name这类链式写法能工作的原因patch_all()依次执行五个补丁见 frappe/query_builder/utils.py 的patch_alldef patch_all(): patch_query_execute() patch_query_aggregation() patch_get_query() patch_like_operators() patch_regex_operator()其中patch_query_execute()给QueryBuilder和_SetOperation挂上了.run()与.walk()方法——run走execute_query执行walk走prepare_query生成参数化 SQL这也是query.run()能绕过frappe.db.sql直接执行的原因patch_query_aggregation()把max / min / avg / sum挂到Base上patch_like_operators()在 PostgreSQL 下把.like()/.not_like()映射为ILIKE/NOT ILIKE保证与 MariaDB 默认大小写不敏感行为一致patch_regex_operator()则把.regex()在 PostgreSQL 下渲染为~*、在 MySQL/MariaDB 下渲染为REGEXP。三、Functions 与 Custom方言差异的函数翻译层文档对这两个文件的定位是These file handle any custom function which needs to be either added or handled separately by the different dialects which are not supported yet by pypika directly.也就是说凡是 pypika 原生没有、或者各数据库实现差异较大的函数都由这两个模块负责补齐。3.1 ImportMapper按数据库自动选实现的分发器这是整个函数翻译层最精巧的机制定义在 frappe/query_builder/utils.pyclass ImportMapper: def __init__(self, func_map: dict[db_type_is, Callable]) - None: self.func_map func_map def __call__(self, *args: Any, **kwds: Any) - Callable: db db_type_is(frappe.conf.db_type) return self.func_mapdbImportMapper在调用时读取frappe.conf.db_type从映射表中取出当前数据库对应的实现来实例化。于是同一个函数名在不同数据库下会生成完全不同的 SQL。典型例子来自 frappe/query_builder/functions.py# 字符串定位MariaDB 用 LOCATEPostgres 用 STRPOSSQLite 用 INSTR Locate ImportMapper({db_type_is.MARIADB: Locate, db_type_is.POSTGRES: Strpos, db_type_is.SQLITE: Instr}) # 分组拼接MariaDB 用 GROUP_CONCATPostgres 用 STRING_AGG GroupConcat ImportMapper({db_type_is.MARIADB: GROUP_CONCAT, db_type_is.POSTGRES: STRING_AGG}) # 全文检索MariaDB 用 MATCH...AGAINSTPostgres 用 TO_TSVECTOR...PLAINTO_TSQUERY Match ImportMapper({db_type_is.MARIADB: MATCH, db_type_is.POSTGRES: TO_TSVECTOR}) # 日期函数MariaDB 用 DATE_FORMATPostgres 用 to_char DateFormat ImportMapper({db_type_is.MARIADB: CustomFunction(DATE_FORMAT, [date, format]), db_type_is.POSTGRES: ToChar}) # 时间戳MariaDB 用 unix_timestampPostgres 用 EXTRACT(EPOCH FROM ...) 再转 BIGINT UnixTimestamp ImportMapper({db_type_is.MARIADB: CustomFunction(unix_timestamp, [date]), db_type_is.POSTGRES: _PostgresUnixTimestamp}) # 日期差MariaDB 用 DATEDIFFPostgres 用日期相减 DateDiff ImportMapper({db_type_is.MARIADB: CustomFunction(DATEDIFF, [date1, date2]), db_type_is.POSTGRES: _PostgresDateDiff}) # JSON 操作MariaDB 用 JSON_EXTRACT / JSON_UNQUOTE / JSON_CONTAINSPostgres 用内建操作符 JSONExtract ImportMapper({db_type_is.MARIADB: _MariaDBJSONExtract, db_type_is.POSTGRES: lambda field, path, **kw: field.get_json_value(path)})这种设计让应用层代码只写一次底层自动适配方言正是Write once, run on all supported databases的核心保障。3.2 custom.py 中的方言专属函数custom.py 实现了几个 pypika 尚未直接支持的函数GROUP_CONCATMariaDB支持separator()链式设置分隔符get_sql会把SEPARATOR xxx注入到闭合括号之前并正确回贴别名STRING_AGGPostgreSQL分隔符作为第二个参数传入separator()链式方法通过替换self.args[1]实现与GROUP_CONCAT.separator()保持接口一致MATCHMariaDB 全文检索必须链式调用.Against(text)才能生成完整的MATCH(col) AGAINST (text* IN BOOLEAN MODE)否则直接抛异常提醒补全TO_TSVECTORPostgreSQL 全文检索固定使用englishregconfig保证函数 immutable、可被 GIN 索引配合.Against(text)生成... PLAINTO_TSQUERY(english, ...)MonthName / Quarter / Month / Year在 MariaDB 上使用原生MONTHNAME / QUARTER / MONTH / YEAR在 PostgreSQL 上改用to_char(..., FMMonth)与date_part(...)其中Quarter / Month / Year还通过_PostgresIntDatePartmixin 把 PostgreSQL 的double precision结果CAST AS INTEGER使两种数据库返回的整数语义完全一致ConstantColumn生成所有行都返回同一常量值的伪列。3.3 functions.py 中的通用函数封装functions.py 在 re-export pypika 全部函数的基础上补充了常用封装Concat_ws、Ifnull向后兼容别名、Timestamp、Round、Truncate、CurDate渲染为无括号的CURRENT_DATE关键字兼容 PostgreSQL 的保留字限制、YearWeek以及Cast_——它专门处理了 MariaDB 没有varchar类型转换的问题用CONCAT(value, )模拟 varchar 语义。此外还提供了SqlFunctions枚举和_max / _min / _avg / _sum四个便捷聚合函数它们通过_aggregate配合PseudoColumn快速完成frappe.qb.get_query(...)的聚合查询。四、Terms参数化的核心枢纽文档对terms的定位非常关键The inherent terms or specific classes of pypika builder are handled and declared here all the parameterization goes through this module (custom parameterization is also implemented here).意思是所有查询的参数化parameterization都经过这一模块自定义参数化机制也在这里实现。这一机制由三个类构成frappe/query_builder/terms.py。4.1 NamedParameterWrapper值收集器NamedParameterWrapper是一个极简的值收集器每次调用get_sql(param_value)时生成一个形如%(param1)s的命名占位符同时把真实值写入内部字典class NamedParameterWrapper: __slots__ (parameters,) def __init__(self) - None: self.parameters {} def get_sql(self, param_value: Any, **kwargs) - str: param_key f%(param{len(self.parameters) 1})s assert param_key[2:-2] not in self.parameters, generated parameter keys must be unique self.parameters[param_key[2:-2]] param_value return param_key def get_parameters(self) - dict[str, Any]: return self.parameters4.2 ParameterizedValueWrapper值包装器补丁ParameterizedValueWrapper替换了 pypika 的ValueWrapper。它的get_sql逻辑是如果传入了param_wrapper且值是字符串则把带引号的字符串作为参数收集起来返回占位符否则对timedelta、time、datetime、bool等 Python 类型做格式化例如datetime调用frappe.db.format_datetimebool转成1/0字符串以保证 MariaDB 与 PostgreSQL 都能接受再走 pypika 原路径。这样生成的 SQL 全部使用%(paramN)s占位符值与语句分离天然免疫 SQL 注入。4.3 ParameterizedFunction 与 SubQueryParameterizedFunction替换 pypika 的Function.get_sql唯一目的就是把param_wrapper透传进get_function_sql让函数参数同样参与参数化。SubQuery别名subqry则把子查询包装成可参与where条件的Criterion。4.4 参数化的落地执行参数化的最终落地在 frappe/query_builder/utils.py 的prepare_query与execute_querydef prepare_query(query): param_collector NamedParameterWrapper() query query.get_sql(param_wrapperparam_collector) ... return query, param_collector.parameters def execute_query(query, *args, **kwargs): dt query.__dict__.get(_doctype) ... query, params prepare_query(query) result frappe.local.db.sql(query, params, *args, **kwargs) # nosemgrep ...prepare_query还会做安全护栏当处于in_safe_execServer Script 沙箱中时先调用check_safe_sql_query检查 SQL 是否安全若检测到调用栈第三帧是服务器脚本文件则直接抛PermissionError(Only SELECT SQL allowed in scripting)处于in_render_safe_exec时则强制check_safe_sql_query(query, throwTrue)。execute_query在拿到结果后还会执行子查询补全execute_child_queries与字段掩码mask_fields基于 DocType 的 masked fields 配置对结果做脱敏并移除内部注入的name字段。这也是.run()与直接frappe.db.sql的重要区别之一。五、Engine多种过滤器与字段记法的统一入口文档多次强调Query Builder 的核心价值在于支撑第三种查询方式而负责handling of various filter field notations的类就是EngineThis module is also where the queryEngineresides which is the class responsible for the handling of various filter field notations.Engine实现在 frappe/database/query.py由frappe/query_builder/utils.py的get_query转发调用def get_query(*args, **kwargs) - QueryBuilder: from frappe.database.query import Engine return Engine().get_query(*args, **kwargs)Engine.get_query是签名最丰富的入口核心参数包括tableDocType 名或 pypikaTable、fields、filters、or_filters、order_by、group_by、limit、offset、distinct、for_update、update / into / delete指定 DML 类型、ignore_permissions、ignore_user_permissions、user、parent_doctype、reference_doctype以及db_query_compat兼容旧db_query排序/过滤行为的开关。它内部会依次解析 fields → 解析 filters → 应用 or_filters → 处理 limit/offset/distinct → 处理 group_by/order_by → 注入权限条件最终返回一个 pypikaQueryBuilder。文档特别声明This module supports almost all the features present in db_query which powersfrappe.get_allandfrappe.get_list.也就是说frappe.get_all/frappe.get_list的绝大多数能力排序、分组、联表、子查询、权限过滤等在Engine中都有对应实现。5.1 三种过滤器记法文档核心示例全解析Engine.apply_filters见 frappe/database/query.py根据filters的类型分派到不同解析路径。文档给出的三种记法如下。① Dict Query字典记法frappe.db.get_values(ToDo, fieldnamename, filters{description: Something Random})等号省略形式等价于description Something Random。字典值的更多可能性apply_dict_filters支持value为[operator, value]二元组# 模糊匹配name LIKE admin% frappe.db.get_values(User, fieldnamename, filters{name: (like, admin%)}) # 集合匹配description IN (somso%, someome) frappe.db.get_values(ToDo, fieldnamename, filters{description: (in, [somso%, someome])})② Misc Query列表记法frappe.db.get_values(ToDo, fieldnamename, filters[description, , someone])这是[field, operator, value]三元组形式。apply_list_filters实际支持四种形态[field, value]省略操作符默认、[field, operator, value]、[doctype, field, operator, value]、以及带第五个元素的变体后两种用于跨 DocType 过滤即文档提到的 implicit joins。此外还有隐式name IN (...)记法当列表元素全是标量值时自动转换为name IN (v1, v2, ...)。列表还支持嵌套逻辑结构如[[cond_a, and, cond_b], or, cond_c]由_parse_nested_filters递归解析成 pypikaCriterion并用/|组合。③ Criterion QueryQuery Builder 对象记法from frappe.query_builder import Field frappe.db.get_values(User, fieldnamename, filtersField(name) Administrator)当filters是 pypikaCriterion时Engine.apply_filters直接self.query self.query.where(filters)因此所有 pypika 过滤器与函数都能直接使用。5.2 过滤器解析的底层细节Engine._build_criterion_for_simple_filter体现了 Engine 在兼容旧行为上做的大量工作值得关注的几处自定义操作符钩子先从 hooks 读取additional_filters_config命中则通过get_additional_filter_field解析后再走标准操作符来自frappe.database.operator_map.OPERATOR_MAP_assign/_liked_by特殊处理这两个字段存储 JSON 用户 ID 数组/!自动转为like/not like并对值加%包裹时间跨度操作符timespan/previous/next经get_date_range展开为between范围Date / Datetime 字段的类型转换对Date字段过滤datetime值时截断为日期对Datetime字段的between过滤自动扩展到00:00:00至23:59:59.999999空列表的 IN 语义IN ()渲染为恒假条件10NOT IN ()渲染为恒真条件11NULL 兼容!与 NULL 比较时按字段类型取_get_ifnull_fallback的默认值字符串、数字0、日期0001-01-01、时间00:00:00并在db_query_compat模式下用IfNull包装可空字段让 MariaDB 与 PostgreSQL 的 NULL 语义一致PostgreSQL 专属适配like自动映射为ilikefrappe/database/query.py 中OPERATOR_MAP[ilike]非文本字段的 like 过滤先CAST(... AS varchar)is set / is not set操作符比较类型化的 fallback 值而非裸避免 PostgreSQL 类型错误嵌套集合操作符ancestors of/descendants of/not ancestors of/not descendants of通过get_nested_set_hierarchy_result展开为IN子句。5.3 字段记法解析Engine.parse_fields与_parse_single_field_item支持多种字段形态*展开为全部列、简单字段、反引号引用字段quoted_field、表限定tabDocType.fieldname、field as alias别名、link_field.target_field点号记法自动联表由DynamicTableField解析并apply_join以及子查询字段记法{roles: [role]}ChildQuery会在结果中对每一行补充子表数据。frappe/tests/test_db_query.py 中test_child_query_without_explicit_name_field验证了省略name时引擎会注入name字段完成子查询、随后在输出中剥离而test_distinct_with_injected_name_raises/test_group_by_with_injected_name_raises则验证了子查询字段 distinct / group_by这种非法组合会被明确拒绝。5.4 排序与分组_validate_order_by支持逗号分隔的多字段排序、asc/desc方向解析、反引号标识符、数字序数按 SELECT 列位置排序以及别名引用_apply_default_order_by依据 DocType 元数据中的sort_field / sort_order通过get_doctype_sort_info获取应用默认排序并支持db_query_compat开关控制默认方向PostgreSQL 下聚合查询的 ORDER BY 字段会通过_normalize_postgres_order_field自动包一层Max()或识别已在 GROUP BY 中 / 已是别名的字段跳过以满足 PostgreSQL SELECT 中必须包含 ORDER BY 表达式的规则DISTINCT与 ORDER BY 冲突时给出UserWarning并忽略排序。六、权限支持Engine 的安全底座值得注意的是文档的 Things to be implemented 一节把权限列为待办但从当前仓库源码看权限能力已经在Engine中实现了。文档中的这一节应被理解为该模块演进历史的记录它描述了设计时的规划方向而当前实现已将其落地。这也正好体现了 docs.md 作为设计文档的价值——阅读时可以对照源码判断哪些规划已实现、哪些仍未完成。从源码看Engine当前的权限链路已经相当完整check_select_permission构造查询时若apply_permissionsTrue即ignore_permissionsFalse先调用frappe.has_permission(doctype, select, ...)校验查询权限apply_field_permissions按 permlevel 过滤 SELECT 字段*会展开为当前用户被允许的字段集合Link 字段还会递归校验目标 DocType 的 select/read 权限子表字段在父表仅有 select 权限时被跳过add_permission_conditions/get_permission_conditions无角色权限时只应用 share 权限name IN (shared_docs)无共享则直接抛 PermissionError有角色权限时组合 if_owner 约束owner user、用户权限User Permission 展开为field IN (...)非严格模式下再OR IfNull(field,)、hooks 与 Server Script 的permission_query_conditions且共享文档始终以 OR 形式凌驾于所有限制之上_check_field_permission对 filter / order by / group by 中出现的每个字段做权限校验只允许 permlevel 0 字段select 权限或标准 permitted fieldsread 权限违规直接抛frappe.PermissionError。因此在应用代码中frappe.qb.get_query(...).run()经由Engine是带权限的查询而裸写frappe.qb.from_(DocType).select(...).run()则跳过权限处理——这一点在 frappe/tests/test_db_query.py 的注释中有明确说明directqb.from_(...).select(...).run()skips permission handling是两种写法的本质区别。七、待办事项与演进方向文档末尾列出了两个设计时的开放问题如实呈现Permissions文档写作时 Query Builder 尚无权限概念规划是将其加入Engine——如上节所述这一项已在当前源码中完成落地补齐frappe.get_list/frappe.get_all的缺失能力文档提出了一个开放性的架构问题——向单一数据库 APIdatabase.py db_query.py演进时get_list/get_all的全部特性是否都要搬进新Engine文档的倾向是Moving to a singular Database API all the support present inget_listandget_allneeds to be present in the newEngineas well however this creates a lot of security cracks, so moving to anew and more restrictive versionof the database API with backward compatibility perhaps would be the right way to go?即全量迁移会带来大量安全漏洞因此更合理的方向是提供一个更严格限制的新版数据库 API并保持向后兼容。这个开放问题至今仍值得架构设计者思考——它体现了 Frappe 在功能完备与攻击面收敛之间的持续权衡。八、实践总结回顾整个模块可以归纳出几条可复用的实践经验优先走数据库 API 或frappe.qb.get_query它们内置参数化、字段校验与权限处理裸写frappe.db.sql仅在极少数需要原生 SQL 的场合使用过滤器尽量用字典记法filters{field: (like, ...)}、filters{field: (in, [...])}表达力足够且可读性好复杂逻辑用嵌套列表或 pypikaCriterion跨数据库代码放心用ImportMapper系列函数GroupConcat、Match、DateFormat、UnixTimestamp、DateDiff、JSONExtract等会自动按 MariaDB / PostgreSQL / SQLite 选择正确实现应用层无需关心方言区分带权限与不带权限的查询需要行级/字段级权限时使用frappe.qb.get_query(...)或frappe.get_all内部工具代码确需绕过权限时再显式ignore_permissionsTrue调试时善用.get_sql()prepare_query会生成带%(paramN)s占位符的参数化 SQL配合NamedParameterWrapper能精确查看每条过滤条件如何被编译。相关文件索引模块入口与 pypika 补丁frappe/query_builder/init.py方言构建器frappe/query_builder/builder.py方言专属函数frappe/query_builder/custom.py通用函数与 ImportMapperfrappe/query_builder/functions.py参数化与 termsfrappe/query_builder/terms.py分发、执行与补丁工具frappe/query_builder/utils.py查询引擎Enginefrappe/database/query.py数据库 API消费方frappe/database/database.py行为验证测试frappe/tests/test_db_query.py【免费下载链接】frappeLow code web framework for real world applications, in Python and Javascript项目地址: https://gitcode.com/GitHub_Trending/fr/frappe创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表