Skip to content

Commit 8189dcc

Browse files
awang923anniezc
andauthored
address review feedback for DuplicateRowCount (#729)
Co-authored-by: anniezc <anniezc@amazon.com>
1 parent 1401bd3 commit 8189dcc

11 files changed

Lines changed: 235 additions & 8 deletions

File tree

src/main/scala/com/amazon/deequ/analyzers/DuplicateRowCount.scala

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ case class DuplicateRowCount(columns: Seq[String], where: Option[String] = None,
4444

4545
override def fromAggregationResult(result: Row, offset: Int, fullColumn: Option[Column]): DoubleMetric = {
4646
if (result.isNullAt(offset)) {
47-
// WHERE clause matched zero rows — return 0 (no duplicates) instead of empty metric
47+
// WHERE clause matched zero rows — return 0 (no duplicates)
4848
toSuccessMetric(0.0, fullColumn)
4949
} else {
5050
val conditionColumn = where.map { expression => expr(expression) }
@@ -78,7 +78,7 @@ case class DuplicateRowCount(columns: Seq[String], where: Option[String] = None,
7878
}
7979
}
8080

81-
/** For empty columns, resolve all DataFrame columns at calculation time */
81+
/** For empty columns, resolve all DataFrame columns and re-wrap metric entity */
8282
override def calculate(
8383
data: DataFrame,
8484
aggregateWith: Option[StateLoader] = None,
@@ -104,6 +104,9 @@ case class DuplicateRowCount(columns: Seq[String], where: Option[String] = None,
104104
}
105105

106106
override def filterCondition: Option[String] = where
107+
108+
override def columnsReferenced(): Option[Set[String]] =
109+
if (columns.isEmpty || where.isDefined) None else Some(columns.toSet)
107110
}
108111

109112
object DuplicateRowCount {

src/main/scala/com/amazon/deequ/analyzers/GroupingAnalyzers.scala

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ object FrequencyBasedAnalyzer {
6767
where: Option[String] = None)
6868
: FrequenciesAndNumRows = {
6969

70+
// Resolve empty groupingColumns to all DataFrame columns.
71+
// Only DuplicateRowCount can reach here with empty columns (it overrides preconditions
72+
// to skip atLeastOne check). All other analyzers enforce atLeastOne(columnsToGroupOn)
73+
// which rejects empty columns before this method is called.
74+
require(groupingColumns.nonEmpty || data.columns.nonEmpty,
75+
"groupingColumns is empty and DataFrame has no columns")
7076
val resolvedColumns = if (groupingColumns.isEmpty) data.columns.toSeq else groupingColumns
7177
val columnsToGroupBy = resolvedColumns.map { name => col(name) }.toArray
7278
val projectionColumns = columnsToGroupBy :+ col(COUNT_COL)

src/main/scala/com/amazon/deequ/constraints/Constraint.scala

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,13 @@ object Constraint {
174174
val constraint = AnalysisBasedConstraint[FrequenciesAndNumRows, Double, Long](
175175
duplicateRowCount, assertion, Some(_.toLong), hint)
176176

177-
new NamedConstraint(constraint, s"DuplicateRowCountConstraint($duplicateRowCount)")
177+
if (columns.nonEmpty) {
178+
new RowLevelGroupedConstraint(constraint,
179+
s"DuplicateRowCountConstraint($duplicateRowCount)",
180+
duplicateRowCount.columns)
181+
} else {
182+
new NamedConstraint(constraint, s"DuplicateRowCountConstraint($duplicateRowCount)")
183+
}
178184
}
179185

180186
/**

src/main/scala/com/amazon/deequ/dqdl/execution/executors/DeequRulesExecutor.scala

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ import com.amazon.deequ.{VerificationResult, VerificationSuite}
2020
import com.amazon.deequ.constraints.{RowLevelAssertedConstraint, RowLevelConstraint, RowLevelGroupedConstraint}
2121
import com.amazon.deequ.dqdl.execution.DQDLExecutor
2222
import com.amazon.deequ.dqdl.model.{DeequExecutableRule, DeequMetricMapping, Failed, NoColumn, RuleOutcome, SingularColumn}
23+
import com.amazon.deequ.dqdl.translation.rules.DuplicateRowCountRule
2324
import org.apache.spark.sql.DataFrame
2425
import org.apache.spark.sql.functions.{col, concat, lit}
26+
import scala.collection.JavaConverters._
2527
import software.amazon.glue.dqdl.model.DQRule
2628

2729
case class DeequExecutionResult(outcomes: Map[DQRule, RuleOutcome], rowLevelData: DataFrame)
@@ -40,9 +42,13 @@ object DeequRulesExecutor extends DQDLExecutor.RuleExecutor[DeequExecutableRule]
4042
return DeequExecutionResult(Map.empty, df)
4143
}
4244

45+
// Resolve empty DuplicateRowCount columns to all DataFrame columns
46+
// so RowLevelGroupedConstraint gets a populated column list for row-level results
47+
val resolvedRules = rules.map(rule => resolveDuplicateRowCountColumns(rule, df))
48+
4349
val verificationResult = VerificationSuite()
4450
.onData(df.toDF())
45-
.addChecks(rules.map(_.check))
51+
.addChecks(resolvedRules.map(_.check))
4652
.run()
4753

4854
val rowLevelData = VerificationResult.rowLevelResultsAsDataFrame(
@@ -68,7 +74,7 @@ object DeequRulesExecutor extends DQDLExecutor.RuleExecutor[DeequExecutableRule]
6874
if (hasRowLevel) Some(check.description) else None
6975
}.toSet
7076

71-
val outcomes = rules.map { r =>
77+
val outcomes = resolvedRules.map { r =>
7278
val rowLevelOutcome = if (rowLevelCheckDescriptions.contains(r.check.description)) {
7379
SingularColumn(r.check.description)
7480
} else {
@@ -102,4 +108,41 @@ object DeequRulesExecutor extends DQDLExecutor.RuleExecutor[DeequExecutableRule]
102108
}
103109
}.toMap
104110
}
111+
112+
/**
113+
* Resolves empty columns in DuplicateRowCount rules to all DataFrame columns.
114+
* This ensures RowLevelGroupedConstraint gets a populated column list for row-level results.
115+
*/
116+
private def resolveDuplicateRowCountColumns(rule: DeequExecutableRule, df: DataFrame): DeequExecutableRule = {
117+
import com.amazon.deequ.checks.{Check, CheckLevel}
118+
import com.amazon.deequ.dqdl.util.DQDLUtility.addWhereClause
119+
import software.amazon.glue.dqdl.model.condition.number.NumberBasedCondition
120+
121+
// Only resolve for DuplicateRowCount with no explicit columns
122+
val isDuplicateRowCountNoColumns = rule.dqRule.getRuleType == "DuplicateRowCount" &&
123+
!rule.dqRule.getParameters.asScala.exists(_._1.startsWith("TargetColumn"))
124+
125+
if (!isDuplicateRowCountNoColumns) {
126+
rule
127+
} else {
128+
// Re-derive the assertion from the DQRule condition
129+
val condition = rule.dqRule.getCondition.asInstanceOf[NumberBasedCondition]
130+
val converter = new DuplicateRowCountRule()
131+
val doubleAssertion = converter.assertionAsScala(rule.dqRule, condition)
132+
val longAssertion: Long => Boolean = (v: Long) => doubleAssertion(v.toDouble)
133+
134+
// Rebuild check with all DataFrame columns
135+
val allColumns = df.columns.toSeq
136+
val check = Check(CheckLevel.Error, rule.check.description)
137+
.hasDuplicateRowCount(allColumns, longAssertion)
138+
139+
val resolvedCheck = if (rule.dqRule.getWhereClause != null && !rule.dqRule.getWhereClause.isEmpty) {
140+
addWhereClause(rule.dqRule, check)
141+
} else {
142+
check
143+
}
144+
145+
DeequExecutableRule(rule.dqRule, resolvedCheck, rule.deequMetricMappings)
146+
}
147+
}
105148
}

src/main/scala/com/amazon/deequ/repository/AnalysisResultSerde.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@ private[deequ] object AnalyzerSerializer
356356
result.add(COLUMNS_FIELD, context.serialize(duplicateRowCount.columns.asJava,
357357
new TypeToken[JList[String]]() {}.getType))
358358
result.addProperty(WHERE_FIELD, duplicateRowCount.where.orNull)
359+
result.add(ANALYZER_OPTIONS_FIELD, context.serialize(duplicateRowCount.analyzerOptions.orNull))
359360

360361
case histogram: Histogram if histogram.binningUdf.isEmpty =>
361362
result.addProperty(ANALYZER_NAME_FIELD, "Histogram")
@@ -598,7 +599,8 @@ private[deequ] object AnalyzerDeserializer
598599
case "DuplicateRowCount" =>
599600
DuplicateRowCount(
600601
getColumnsAsSeq(context, json),
601-
getOptionalWhereParam(json))
602+
getOptionalWhereParam(json),
603+
analyzerOptions = getOptionalAnalyzerOptions(json))
602604

603605
case "Histogram" =>
604606
Histogram(

src/test/scala/com/amazon/deequ/analyzers/AnalyzerTests.scala

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,21 @@ class AnalyzerTests extends AnyWordSpec with Matchers with SparkContextSpec with
6363
}
6464
}
6565

66+
"DuplicateRowCount analyzer" should {
67+
"compute correct metrics" in withSparkSession { sparkSession =>
68+
import sparkSession.implicits._
69+
val df = Seq(("a", 1), ("b", 2), ("a", 1), ("c", 3)).toDF("col1", "col2")
70+
val result = DuplicateRowCount(Seq("col1", "col2")).calculate(df).value
71+
result shouldBe Success(2.0)
72+
}
73+
"return 0 when no duplicates" in withSparkSession { sparkSession =>
74+
import sparkSession.implicits._
75+
val df = Seq(("a", 1), ("b", 2), ("c", 3)).toDF("col1", "col2")
76+
val result = DuplicateRowCount(Seq("col1", "col2")).calculate(df).value
77+
result shouldBe Success(0.0)
78+
}
79+
}
80+
6681
"Completeness analyzer" should {
6782

6883
"compute correct metrics" in withSparkSession { sparkSession =>

src/test/scala/com/amazon/deequ/analyzers/DuplicateRowCountTest.scala

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,5 +150,117 @@ class DuplicateRowCountTest extends AnyWordSpec with Matchers with SparkContextS
150150
val result = DuplicateRowCount(Seq("col1", "col2")).calculate(df)
151151
result.value shouldBe scala.util.Success(0.0)
152152
}
153+
154+
"correctly merge state across partitions" in withSparkSession { session =>
155+
import session.implicits._
156+
// Partition A: ("a",1) is unique
157+
val dfA = Seq(("a", 1), ("b", 2)).toDF("col1", "col2")
158+
// Partition B: ("a",1) appears again - now duplicate across A+B
159+
val dfB = Seq(("a", 1), ("c", 3)).toDF("col1", "col2")
160+
161+
val analyzer = DuplicateRowCount(Seq("col1", "col2"))
162+
val stateA = analyzer.computeStateFrom(dfA).get
163+
val stateB = analyzer.computeStateFrom(dfB).get
164+
val merged = stateA.sum(stateB)
165+
166+
val metric = analyzer.computeMetricFrom(Some(merged))
167+
// ("a",1) has count 2 after merge -> 2 duplicate rows
168+
metric.value shouldBe Success(2.0)
169+
}
170+
171+
"correctly merge state with overlapping groups" in withSparkSession { session =>
172+
import session.implicits._
173+
// Partition A: ("a",1) appears twice
174+
val dfA = Seq(("a", 1), ("a", 1), ("b", 2)).toDF("col1", "col2")
175+
// Partition B: ("a",1) appears once more
176+
val dfB = Seq(("a", 1), ("c", 3)).toDF("col1", "col2")
177+
178+
val analyzer = DuplicateRowCount(Seq("col1", "col2"))
179+
val stateA = analyzer.computeStateFrom(dfA).get
180+
val stateB = analyzer.computeStateFrom(dfB).get
181+
val merged = stateA.sum(stateB)
182+
183+
val metric = analyzer.computeMetricFrom(Some(merged))
184+
// ("a",1) has count 3 after merge -> 3 duplicate rows
185+
metric.value shouldBe Success(3.0)
186+
}
187+
188+
"produce correct row-level results" in withSparkSession { session =>
189+
import session.implicits._
190+
import com.amazon.deequ.{VerificationSuite, VerificationResult}
191+
import com.amazon.deequ.checks.{Check, CheckLevel, CheckStatus}
192+
193+
val df = Seq(("a", 1), ("b", 2), ("a", 1), ("c", 3)).toDF("col1", "col2")
194+
195+
val result = VerificationSuite()
196+
.onData(df)
197+
.addCheck(Check(CheckLevel.Error, "dup-check")
198+
.hasDuplicateRowCount(Seq("col1", "col2"), _ == 2))
199+
.run()
200+
201+
// Verify the check passes
202+
result.status shouldBe CheckStatus.Success
203+
204+
// Verify row-level results: true = duplicate, false = unique
205+
val rowLevelDf = VerificationResult.rowLevelResultsAsDataFrame(session, result, df)
206+
val flags = rowLevelDf.select("`dup-check`").collect().map(_.getBoolean(0))
207+
// 2 rows are duplicates (true), 2 rows are unique (false)
208+
flags.count(_ == true) shouldBe 2
209+
flags.count(_ == false) shouldBe 2
210+
}
211+
212+
"work with empty columns through VerificationSuite" in withSparkSession { session =>
213+
import session.implicits._
214+
import com.amazon.deequ.{VerificationSuite, VerificationResult}
215+
import com.amazon.deequ.checks.{Check, CheckLevel, CheckStatus}
216+
217+
val df = Seq(("a", 1), ("b", 2), ("a", 1), ("c", 3)).toDF("col1", "col2")
218+
219+
val result = VerificationSuite()
220+
.onData(df)
221+
.addCheck(Check(CheckLevel.Error, "dup-empty-cols")
222+
.hasDuplicateRowCount(Seq.empty, _ == 2))
223+
.run()
224+
225+
// Empty columns resolves to all columns at runtime
226+
result.status shouldBe CheckStatus.Success
227+
}
228+
229+
"not crash with empty columns through constraint path" in withSparkSession { session =>
230+
import session.implicits._
231+
import com.amazon.deequ.constraints.Constraint
232+
233+
val df = Seq(("a", 1), ("b", 2), ("a", 1)).toDF("col1", "col2")
234+
// Should not throw NoSuchElementException (NamedConstraint fallback for empty columns)
235+
val constraint = Constraint.duplicateRowCountConstraint(Seq.empty, _ == 2)
236+
constraint should not be null
237+
}
238+
239+
"produce row-level results for empty columns through DeequRulesExecutor" in withSparkSession { session =>
240+
import session.implicits._
241+
import com.amazon.deequ.{VerificationSuite, VerificationResult}
242+
import com.amazon.deequ.checks.{Check, CheckLevel, CheckStatus}
243+
244+
val df = Seq(("a", 1), ("b", 2), ("a", 1), ("c", 3)).toDF("col1", "col2")
245+
246+
// Simulate what DeequRulesExecutor does: resolve empty columns then run
247+
val allColumns = df.columns.toSeq
248+
val result = VerificationSuite()
249+
.onData(df)
250+
.addCheck(Check(CheckLevel.Error, "dup-resolved")
251+
.hasDuplicateRowCount(allColumns, _ == 2))
252+
.run()
253+
254+
result.status shouldBe CheckStatus.Success
255+
256+
// With resolved columns, RowLevelGroupedConstraint is used -> row-level results exist
257+
val rowLevelDf = VerificationResult.rowLevelResultsAsDataFrame(session, result, df)
258+
rowLevelDf.columns should contain ("dup-resolved")
259+
260+
// Verify flags: 2 duplicates (true), 2 unique (false)
261+
val flags = rowLevelDf.select("`dup-resolved`").collect().map(_.getBoolean(0))
262+
flags.count(_ == true) shouldBe 2
263+
flags.count(_ == false) shouldBe 2
264+
}
153265
}
154266
}

src/test/scala/com/amazon/deequ/analyzers/NullHandlingTests.scala

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,5 +161,29 @@ class NullHandlingTests extends AnyWordSpec
161161
assert(metric.value.failed.get.isInstanceOf[EmptyStateException])
162162
}
163163

164+
"DuplicateRowCount" should {
165+
"exclude all-null rows and return 0 duplicates" in withSparkSession { session =>
166+
import session.implicits._
167+
val df = Seq(
168+
(None: Option[String], None: Option[String]),
169+
(None: Option[String], None: Option[String]),
170+
(Some("a"), Some("b"))
171+
).toDF("col1", "col2")
172+
val result = DuplicateRowCount(Seq("col1", "col2")).calculate(df)
173+
assert(result.value == Success(0.0))
174+
}
175+
176+
"treat partial nulls as equal for grouping" in withSparkSession { session =>
177+
import session.implicits._
178+
val df = Seq(
179+
(Some("a"), None: Option[String]),
180+
(Some("a"), None: Option[String]),
181+
(Some("b"), Some("c"))
182+
).toDF("col1", "col2")
183+
val result = DuplicateRowCount(Seq("col1", "col2")).calculate(df)
184+
assert(result.value == Success(2.0))
185+
}
186+
}
187+
164188

165189
}

src/test/scala/com/amazon/deequ/analyzers/StateProviderTest.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ class StateProviderTest extends AnyWordSpec
7474
assertCorrectlyRestoresFrequencyBasedState(provider, provider,
7575
Uniqueness(Seq("att1", "count")), data)
7676
assertCorrectlyRestoresFrequencyBasedState(provider, provider, Entropy("att1"), data)
77+
assertCorrectlyRestoresFrequencyBasedState(provider, provider,
78+
DuplicateRowCount(Seq("att1", "count")), data)
7779

7880
assertCorrectlyApproxQuantileState(provider, provider, ApproxQuantile("price", 0.5), data)
7981
}

src/test/scala/com/amazon/deequ/checks/CheckTest.scala

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,19 @@ class CheckTest extends AnyWordSpec with Matchers with SparkContextSpec with Fix
282282
assert(constraintStatuses(9) == ConstraintStatus.Success)
283283
}
284284

285+
"return the correct check status for hasDuplicateRowCount" in withSparkSession { sparkSession =>
286+
import sparkSession.implicits._
287+
val df = Seq(("a", 1), ("b", 2), ("a", 1), ("c", 3)).toDF("col1", "col2")
288+
289+
val check = Check(CheckLevel.Error, "duplicate-row-count-check")
290+
.hasDuplicateRowCount(Seq("col1", "col2"), _ == 2)
291+
292+
val context = runChecks(df, check)
293+
val result = check.evaluate(context)
294+
295+
assert(result.status == CheckStatus.Success)
296+
}
297+
285298
"return the correct check status for hasUniqueValueRatio" in withSparkSession { sparkSession =>
286299

287300
val check = Check(CheckLevel.Error, "unique-value-ratio-check")

0 commit comments

Comments
 (0)