在这里插入图片描述
在这里插入图片描述

实例:课程表管理(Course)|技术:二维坐标查询、冲突检测、网格数据流

一、课程表查询的三个层次

课程表的查询体系是**「坐标定位」**的:queryAll(全量排序)→ queryByDay(按天)→ checkConflict(坐标查重)。本篇文章逐一深入,重点讲 冲突检测二维坐标查询——排课系统的核心逻辑。

二、queryAll:课表数据源(双键排序)

课表渲染需要全部课程按「周几 + 节次」排序:

static async queryAll(context: common.Context): Promise<Course[]> {
  const store = await CourseDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(CourseDao.TABLE);
  predicates.orderByAsc('weekday').orderByAsc('section');
  const result = await store.query(predicates);
  return CourseDao.collect(result);
}
SELECT * FROM course ORDER BY weekday ASC, section ASC;

双键排序:先按 weekday(周一~周日)再按 section(第 1~8 节)——排序结果天然是「课表扫描顺序」。为什么页面还要 filter? queryAll 给出全局有序数据,页面按 c.weekday === currentDay && c.section === section 过滤当前格子的课程——**「数据库排序 + 页面过滤」**分工:排序数据库做(高效),过滤页面做(数据已在内存)。

queryAll 的双键排序 + 页面 filter 的模式:数据量小(14 门)时一次取回全部、页面自由筛选——比「每次切换星期都查库」流畅(无网络/磁盘延迟,纯内存 filter)。

三、queryByDay:按天查询(等值 + 节次升序)

static async queryByDay(context: common.Context, weekday: number): Promise<Course[]> {
  const store = await CourseDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(CourseDao.TABLE);
  predicates.equalTo('weekday', weekday).orderByAsc('section');
  const result = await store.query(predicates);
  return CourseDao.collect(result);
}
SELECT * FROM course WHERE weekday = 1 ORDER BY section ASC;

等值 + 排序equalTo('weekday', weekday) 过滤某天 + orderByAsc('section') 按节次排——「周一课程按节次列出来」。命中联合索引idx_course_grid (weekday, section)——weekday 等值定位 + section 排序都在索引内(覆盖排序)。

queryByDay 与页面的 dayCourses 的关系:落地页面用 dayCourses(内存 filter)而非 queryByDay(查库)——因为数据已在 queryAll 里。queryByDay 保留为「按天拉取」的独立 API(未来数据量大时分页加载某天)。

四、checkConflict:冲突检测(核心方法)

冲突检测是课程表区别于所有前面实例的独特方法

/** 冲突检测:同一天同一节次是否已有课 */
static async checkConflict(context: common.Context, weekday: number, section: number, excludeId: number): Promise<boolean> {
  const store = await CourseDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(CourseDao.TABLE);
  predicates.equalTo('weekday', weekday).equalTo('section', section);
  if (excludeId > 0) {
    predicates.notEqualTo('id', excludeId);
  }
  const result = await store.query(predicates);
  const has = result.goToNextRow();
  result.close();
  return has;
}
-- 排除自身(编辑场景)
SELECT * FROM course WHERE weekday = 1 AND section = 3 AND id != 5;

逐行拆解

1. 同坐标查重equalTo('weekday', weekday).equalTo('section', section)——查「(weekday, section) 这个格子有没有记录」。坐标查重 = 二维等值 AND:两个坐标都相等才算同一格。

2. excludeId 排除自身:编辑课程换时间段时(把周一第 1 节移到周二第 2 节),查询会命中「课程自己原来占的格子」——不排除会永远报冲突。excludeId > 0 守卫:新增传 0(不排除),编辑传自身 id(排除)——一个方法兼顾新增与编辑

3. 返回 booleangoToNextRow() 有行即冲突——「查第一行即可」:冲突检测不需要遍历全部,一行命中即 true。

4. 为什么用「查询」而非「UNIQUE 约束」? 两种方案都能防冲突:

  • UNIQUE (weekday, section) 约束:数据库硬性拒绝,插入抛异常——但编辑换时间也会被旧记录挡住(需先删旧记录或事务),灵活度低、报错不友好;
  • 应用层 checkConflict:查询 → 页面判断 → Toast 提示「该时段已有课程,冲突!」——「检测-提示-阻止」的友好体验,编辑可自由换时间(excludeId 排除自己)。

落地页面的调用

async onSave(): Promise<void> {
  if (!this.fName.trim()) {
    promptAction.showToast({ message: '课程名必填' });
    return;
  }
  const conflict = await CourseDao.checkConflict(this.context, this.fWeekday, this.fSection, 0);
  if (conflict) {
    promptAction.showToast({ message: '⚠️ 该时段已有课程,冲突!' });
    return;
  }
  // ... insert
}

保存前先检测:课程名校验(必填)→ 冲突检测(坐标查重)→ 通过才 insert——「校验链」:先格式后业务

五、insert/update:全量写

static async insert(context: common.Context, c: Course): Promise<number> {
  const store = await CourseDao.getStore(context);
  const values: relationalStore.ValuesBucket = {
    name: c.name, teacher: c.teacher, location: c.location,
    weekday: c.weekday, section: c.section, color: c.color,
    weeks: c.weeks, remark: c.remark,
  };
  return await store.insert(CourseDao.TABLE, values);
}

insert 不内置冲突检测——检测在页面层(checkConflict 调用),DAO 只做「写入」。「检测与写入分离」:checkConflict 是查询方法(可单独复用),insert 是写方法(不做业务判断)——职责单一,页面组合它们。

update 全量更新(编辑课程全部字段覆盖)——与便签/影音同款模式。

六、count 与网格数据流

static async count(context: common.Context): Promise<number> {
  const store = await CourseDao.getStore(context);
  const result = await store.querySql(`SELECT COUNT(*) AS c FROM ${CourseDao.TABLE}`);
  let total = 0;
  if (result.goToNextRow()) {
    total = result.getLong(result.getColumnIndex('c'));
  }
  result.close();
  return total;
}

完整数据流(页面):

aboutToAppear → refresh
  → initSeedData(首启填 14 门)
  → queryAll(全部课程,weekday+section 排序)
  → courses 状态
切换星期 → currentDay 变化 → 页面 filter(dayCourses / 格子 filter)
点格子 → showCourseMenu(详情/删除)
添加 → 校验链 → checkConflict → insert → refresh

核心链路queryAll 一次拉全 → 页面按坐标 filter——课表网格的渲染完全在内存进行,切换星期零延迟。「一次拉取 + 内存过滤」是网格类页面的标准数据流(数据量中等时)。

七、技术要点对照表

技术点 实现方式 生产价值
坐标查重 双 equalTo AND 冲突检测
排除自身 notEqualTo(‘id’, excludeId) 编辑不误报
双键排序 weekday + section ASC 课表顺序
联合索引 idx_course_grid 坐标查询加速
检测与写分离 checkConflict + insert 独立 职责单一
校验链 必填 → 冲突 → 写入 友好拦截

八、常见问题 FAQ

Q1:冲突检测为什么不用 UNIQUE 约束?
A:UNIQUE 是「硬拒绝」(插入抛异常),应用层是「软提示」(Toast 告知)。课程表需要编辑换时间的场景——UNIQUE 会挡住编辑(旧记录占着格子),应用层用 excludeId 优雅排除。**「软提示 + 灵活编辑」优先于「硬约束」**是交互型应用的选择;纯数据完整性场景才用 UNIQUE。

Q2:连堂课(两节连上)怎么处理冲突?
A:连堂是两条记录(section=1 和 section=2)——冲突检测分别对两个格子查重。「连堂 = 相邻格子的多条记录」,每格独立检测,天然无冲突问题(除非两门课抢同一格)。

Q3:为什么页面用 filter 而不用 queryByDay?
A:数据已由 queryAll 一次拉全在内存,filter 是纯内存操作(微秒级);queryByDay 是数据库查询(毫秒级 + 返回 Promise 异步)。切换星期用 filter 零延迟、无闪烁「数据已在手就内存处理」——queryByDay 留给「数据量大需按天拉取」的未来。

Q4:checkConflict 返回 boolean,如果还想知道是哪门课冲突呢?
A:boolean 够用(页面只要「有没有冲突」)。想显示「与高等数学冲突」可改返回 Course|null——查询逻辑不变,返回第一行实体。按需求调整返回粒度,boolean 是「只需判断」的最小返回。

Q5:excludeId 传 0 的语义是什么?
A:0 是「无排除」的哨兵值(id 从 1 自增,不存在 id=0)——excludeId > 0 守卫让新增(传 0)不排除任何课程,编辑(传自身 id)排除自己。哨兵值 0 表示「不启用该参数」——与健康数据 status=-1 同模式。

Q6:课表数据量大了(几百门课)怎么办?
A:queryAll 全拉 + filter 会变慢(每次渲染全量过滤)。优化方向:切换星期改 queryByDay(数据库 WHERE 过滤)+ 列表懒加载。「一次拉取」适用于中小数据量,大数据量回退「按需查询」——架构预留了 queryByDay。

九、文章小结

本篇文章深入讲解了课程表的数据层核心:冲突检测(同坐标双等值查重 + excludeId 排除自身的哨兵模式)坐标查询(双键排序 + 联合索引加速)「检测与写入分离」的职责划分「一次拉取 + 内存 filter」的网格数据流。核心心法是「二维坐标 = 双等值 AND」与「应用层软提示优于硬约束」——排课系统的通用逻辑。

下一篇(15-4)展示 14 门课程种子数据,让课表网格一开屏就格子饱满。

十、checkConflict 深入:为什么不用 UNIQUE 约束

UNIQUE 约束的方案长什么样? 建表时加 UNIQUE(weekday, section),由数据库硬性保证「同一天同一节次唯一」:

CREATE TABLE course (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  weekday INTEGER NOT NULL,
  section INTEGER NOT NULL,
  UNIQUE (weekday, section)
);

插入冲突时 store.insertSqliteException(SQLITE_CONSTRAINT_UNIQUE)。表面更「安全」,实战却有三个痛点:

痛点 UNIQUE 约束 应用层 checkConflict
新增冲突 抛异常,需 try/catch 解析 Toast 直接提示「该时段已有课程」
编辑换时间 旧记录占原格,插新坐标必冲突,需先删旧再插(两步 + 事务) excludeId 排除自身,一步到位
报错体验 异常信息对用户不可读 提示文案完全可控

excludeId 的两个编辑场景数据流

场景 A(坐标不变,只改课程名):课程 A(id=3)在 (2,2)
  checkConflict(weekday=2, section=2, excludeId=3)
  SQL: WHERE weekday=2 AND section=2 AND id != 3
  → 不排除会命中 A 自己 → 误报冲突,改个名字都保存不了
场景 B(换时间):(1,1) → (2,2)
  → 检测发生在 update 前后任一时刻,记录可能已在新格
  → excludeId 让「检测」与「被编辑记录」解耦,两种场景都不误报

excludeId 的本质:检测的是「除当前编辑记录外的其他记录」——「排除自己再查重」是编辑场景冲突检测的通用范式

十一、坐标查询的联合索引命中分析

idx_course_grid (weekday, section) 的 B+ 树按「weekday 升序、同 weekday 内 section 升序」组织——索引结构本身就是课表顺序。逐条分析本篇的查询:

查询 谓词/排序 索引命中 分析
queryByDay WHERE weekday=1 ORDER BY section ✅ 完全命中 等值定位周一子树,section 已有序,覆盖排序、零回表
checkConflict WHERE weekday=? AND section=? ✅ 完全命中 双等值在索引内定位到单叶子,取第一行即止
queryAll ORDER BY weekday, section ✅ 索引扫描 索引顺序 = 排序顺序,顺序扫描即有序结果
反例:只看节次 WHERE section=3 ❌ 未命中 违反最左前缀,section 非首列,退化为全表扫描

最左前缀原则:联合索引 (weekday, section) 只能加速「以 weekday 开头」的查询。所以冲突检测传参刻意是 (weekday, section) 而非 (section, weekday)——参数顺序对齐索引列序,查询才能命中。这也是为什么 checkConflict 签名把 weekday 放前面、queryByDay 也按此顺序组织。

十二、queryAll 双键排序与页面 filter 的数据流

DB 层:queryAll 按 (weekday ASC, section ASC) 返回全局有序数组
        ↓ courses: Course[](一次拉全,仅一次排序)
页面层:currentDay 状态驱动纯内存过滤
        ↓ dayCourses = courses.filter(c => c.weekday === currentDay)
        ↓ 格子 filter:courses.filter(c => c.weekday === d && c.section === s)
渲染层:网格按坐标取数,空格显示「无课」占位
职责 数据形态
数据库 排序(ORDER BY 双键,一次) 14 条有序记录
页面状态 缓存全部 + 当前星期 courses / currentDay
filter 坐标过滤(纯内存、无异步、微秒级) dayCourses / 格子课程
渲染 按格取值 色块 + 课程名

分层的意义:数据库只做一次排序,页面反复 filter 无成本;切换星期只改 currentDay 一个状态——「排序下沉到 DB、过滤上浮到页面」,中间零二次查询、零闪烁。

十三、FAQ 补充

Q7:联合索引能改成 (section, weekday) 吗?
A:不能。本应用所有查询都是「按天定位」(weekday 在前),(section, weekday) 会让 queryByDay、checkConflict 全部退化为全表扫描。索引列序必须对齐最频繁的查询形态,而不是反过来迁就查询。

Q8:编辑换时间后,旧坐标的课程会残留吗?
A:不会。update 是全量覆盖——旧坐标字段被新坐标直接覆盖,同一条记录换位置。「换坐标 = 更新两个字段」,无需删除重建;而 UNIQUE 方案必须「先删旧记录再插新坐标」,中间有空窗,这正是放弃 UNIQUE 的另一个原因。

Q9:checkConflict 只取第一行,为什么不用 COUNT?
A:goToNextRow() 命中一行即返回 true,代价是「读到一行」;COUNT 要扫完所有命中行。「存在性判断取第一行,数量统计才用 COUNT」——这是 SQL 存在性查询的通用写法,同样适用于 count() 方法之外的所有「有没有」判断。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐